mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-20 06:29:07 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ce7717f26d | |||
| 0106145a45 | |||
| e4cfa1d17d |
@@ -4,7 +4,7 @@
|
|||||||
"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,7 +13,7 @@
|
|||||||
"@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",
|
||||||
@@ -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,5 +1,5 @@
|
|||||||
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 } from 'react';
|
import { memo } from 'react';
|
||||||
import { IoClose, IoContract, IoExpand, IoLockClosedOutline, 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';
|
||||||
@@ -33,7 +33,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();
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ interface UseFollowComponentProps {
|
|||||||
scrollRef: RefObject<HTMLElement | null>;
|
scrollRef: RefObject<HTMLElement | null>;
|
||||||
doFollow: boolean;
|
doFollow: boolean;
|
||||||
topOffset?: number;
|
topOffset?: number;
|
||||||
|
/**
|
||||||
|
* Resolved at scroll time, for offsets which can only be measured from the DOM (eg. a sticky header).
|
||||||
|
* Takes precedence over topOffset. Keep the reference stable, it is a dependency of the follow effect.
|
||||||
|
*/
|
||||||
|
getTopOffset?: () => number;
|
||||||
setScrollFlag?: (newValue: boolean) => void;
|
setScrollFlag?: (newValue: boolean) => void;
|
||||||
followTrigger?: MaybeString; // this would be an entry id or null
|
followTrigger?: MaybeString; // this would be an entry id or null
|
||||||
}
|
}
|
||||||
@@ -31,6 +36,7 @@ export default function useFollowComponent({
|
|||||||
scrollRef,
|
scrollRef,
|
||||||
doFollow,
|
doFollow,
|
||||||
topOffset = 100,
|
topOffset = 100,
|
||||||
|
getTopOffset,
|
||||||
setScrollFlag,
|
setScrollFlag,
|
||||||
followTrigger,
|
followTrigger,
|
||||||
}: UseFollowComponentProps) {
|
}: UseFollowComponentProps) {
|
||||||
@@ -44,21 +50,23 @@ export default function useFollowComponent({
|
|||||||
setScrollFlag?.(true);
|
setScrollFlag?.(true);
|
||||||
// Use requestAnimationFrame to ensure the component is fully loaded
|
// Use requestAnimationFrame to ensure the component is fully loaded
|
||||||
window.requestAnimationFrame(() => {
|
window.requestAnimationFrame(() => {
|
||||||
scrollToComponent(followRef as RefObject<HTMLElement>, scrollRef as RefObject<HTMLElement>, topOffset);
|
// resolve the offset after layout, so that measured values are up to date
|
||||||
|
const offset = getTopOffset?.() ?? topOffset;
|
||||||
|
scrollToComponent(followRef as RefObject<HTMLElement>, scrollRef as RefObject<HTMLElement>, offset);
|
||||||
setScrollFlag?.(false);
|
setScrollFlag?.(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [followTrigger, doFollow, followRef, scrollRef, setScrollFlag, topOffset]);
|
}, [followTrigger, doFollow, followRef, scrollRef, setScrollFlag, topOffset, getTopOffset]);
|
||||||
|
|
||||||
const scrollToRefComponent = useCallback(
|
const scrollToRefComponent = useCallback(
|
||||||
(componentRef = followRef, containerRef = scrollRef, offset = topOffset) => {
|
(componentRef = followRef, containerRef = scrollRef, offset?: number) => {
|
||||||
if (componentRef && containerRef) {
|
if (componentRef && containerRef) {
|
||||||
// @ts-expect-error -- we know this are not null
|
// @ts-expect-error -- we know this are not null
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
scrollToComponent(componentRef!, containerRef!, offset);
|
scrollToComponent(componentRef!, containerRef!, offset ?? getTopOffset?.() ?? topOffset);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[followRef, scrollRef, topOffset],
|
[followRef, scrollRef, topOffset, getTopOffset],
|
||||||
);
|
);
|
||||||
|
|
||||||
return scrollToRefComponent;
|
return scrollToRefComponent;
|
||||||
|
|||||||
@@ -18,6 +18,14 @@
|
|||||||
padding-bottom: 95vh;
|
padding-bottom: 95vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* a group and its events, bounds the sticky group header to its own section.
|
||||||
|
repeats the gap of the surrounding list, which no longer reaches the rows of a section */
|
||||||
|
.groupSection {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.editPrompt {
|
.editPrompt {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: $zindex-dialog;
|
z-index: $zindex-dialog;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { OntimeView, isOntimeEvent, isOntimeGroup } from 'ontime-types';
|
import { OntimeView, isOntimeEvent, isOntimeGroup } from 'ontime-types';
|
||||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import EmptyFill from '../../common/components/state/EmptyFill';
|
import EmptyFill from '../../common/components/state/EmptyFill';
|
||||||
import EmptyPage from '../../common/components/state/EmptyPage';
|
import EmptyPage from '../../common/components/state/EmptyPage';
|
||||||
@@ -54,11 +54,21 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
|
|||||||
const [lockAutoScroll, setLockAutoScroll] = useState(false);
|
const [lockAutoScroll, setLockAutoScroll] = useState(false);
|
||||||
const selectedRef = useRef<HTMLDivElement | null>(null);
|
const selectedRef = useRef<HTMLDivElement | null>(null);
|
||||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const stickyHeaderRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
// the group of the running event is pinned to the top of the list, the running event needs to clear it
|
||||||
|
const getStickyOffset = useCallback(() => {
|
||||||
|
const header = stickyHeaderRef.current;
|
||||||
|
// account for the list gap, so that the rows do not touch
|
||||||
|
return header ? header.offsetHeight + 2 : 0;
|
||||||
|
}, []);
|
||||||
|
const getTopOffset = useCallback(() => selectedOffset + getStickyOffset(), [getStickyOffset]);
|
||||||
|
|
||||||
const scrollToComponent = useFollowComponent({
|
const scrollToComponent = useFollowComponent({
|
||||||
followRef: selectedRef,
|
followRef: selectedRef,
|
||||||
scrollRef,
|
scrollRef,
|
||||||
doFollow: !lockAutoScroll,
|
doFollow: !lockAutoScroll,
|
||||||
topOffset: selectedOffset,
|
getTopOffset,
|
||||||
followTrigger: selectedEventId,
|
followTrigger: selectedEventId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -86,7 +96,8 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
|
|||||||
const selectedRect = selectedRef.current.getBoundingClientRect();
|
const selectedRect = selectedRef.current.getBoundingClientRect();
|
||||||
const scrollerRect = scrollRef.current.getBoundingClientRect();
|
const scrollerRect = scrollRef.current.getBoundingClientRect();
|
||||||
if (selectedRect && scrollerRect) {
|
if (selectedRect && scrollerRect) {
|
||||||
const distanceFromTop = selectedRect.top - scrollerRect.top;
|
// discount the pinned group header, so that the threshold keeps its meaning when a header is stuck
|
||||||
|
const distanceFromTop = selectedRect.top - scrollerRect.top - getStickyOffset();
|
||||||
const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > selectedOffset;
|
const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > selectedOffset;
|
||||||
setLockAutoScroll(hasScrolledOutOfThreshold);
|
setLockAutoScroll(hasScrolledOutOfThreshold);
|
||||||
}
|
}
|
||||||
@@ -186,13 +197,14 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment key={entry.id}>
|
<div className={style.groupSection} key={entry.id}>
|
||||||
<OperatorGroup
|
<OperatorGroup
|
||||||
key={entry.id}
|
ref={isCurrentParent ? stickyHeaderRef : undefined}
|
||||||
title={entry.title}
|
title={entry.title}
|
||||||
colour={entry.colour}
|
colour={entry.colour}
|
||||||
count={entry.entries.length}
|
count={entry.entries.length}
|
||||||
duration={entry.duration}
|
duration={entry.duration}
|
||||||
|
isLive={isCurrentParent}
|
||||||
/>
|
/>
|
||||||
{entry.entries.map((nestedEntryId) => {
|
{entry.entries.map((nestedEntryId) => {
|
||||||
const nestedEntry = rundown.entries[nestedEntryId];
|
const nestedEntry = rundown.entries[nestedEntryId];
|
||||||
@@ -239,7 +251,7 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Fragment>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -3,17 +3,32 @@
|
|||||||
min-height: 2.5rem;
|
min-height: 2.5rem;
|
||||||
padding: 0.4rem 0.75rem;
|
padding: 0.4rem 0.75rem;
|
||||||
border-left: 0.35rem solid var(--group-colour, $gray-500);
|
border-left: 0.35rem solid var(--group-colour, $gray-500);
|
||||||
background-color: $gray-1350;
|
/* the background comes inline from getAccessibleColour, which resolves to an opaque colour
|
||||||
background: color-mix(in srgb, transparent 88%, var(--group-colour, $gray-500) 12%);
|
and is what keeps the rows from showing through while the header is pinned */
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|
||||||
|
/* stays visible as a header for the events of the group, bound by the group section */
|
||||||
|
position: sticky;
|
||||||
|
/* covers the list padding, otherwise the rows scroll through it */
|
||||||
|
top: -0.25rem;
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* a group holding the running event is only outlined, the green fill belongs to the event row itself.
|
||||||
|
the card is already filled with the group colour, so we use the lighter active indicator over a dark
|
||||||
|
frame, which stays legible against any of them, green groups included */
|
||||||
|
.live {
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 2px $ui-black,
|
||||||
|
inset 0 0 0 4px $active-indicator;
|
||||||
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { CSSProperties, memo } from 'react';
|
import { CSSProperties, Ref, memo } from 'react';
|
||||||
|
|
||||||
import { getAccessibleColour } from '../../../common/utils/styleUtils';
|
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||||
import { formatDuration } from '../../../common/utils/time';
|
import { formatDuration } from '../../../common/utils/time';
|
||||||
|
|
||||||
import style from './OperatorGroup.module.scss';
|
import style from './OperatorGroup.module.scss';
|
||||||
@@ -10,15 +10,22 @@ interface OperatorGroup {
|
|||||||
colour: string;
|
colour: string;
|
||||||
count: number;
|
count: number;
|
||||||
duration: number;
|
duration: number;
|
||||||
|
/** whether the group contains the currently running event */
|
||||||
|
isLive?: boolean;
|
||||||
|
ref?: Ref<HTMLDivElement>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(OperatorGroup);
|
export default memo(OperatorGroup);
|
||||||
function OperatorGroup({ title, colour, count, duration }: OperatorGroup) {
|
function OperatorGroup({ title, colour, count, duration, isLive, ref }: OperatorGroup) {
|
||||||
const groupColour = colour || '#929292';
|
const groupColour = colour || '#929292';
|
||||||
const groupColours = getAccessibleColour(groupColour);
|
const groupColours = getAccessibleColour(groupColour);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.group} style={{ ...groupColours, '--group-colour': groupColour } as CSSProperties}>
|
<div
|
||||||
|
className={cx([style.group, isLive && style.live])}
|
||||||
|
style={{ ...groupColours, '--group-colour': groupColour } as CSSProperties}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
<span className={style.title}>{title}</span>
|
<span className={style.title}>{title}</span>
|
||||||
<span className={style.meta}>
|
<span className={style.meta}>
|
||||||
<span>{`${count} ${count === 1 ? 'event' : 'events'}`}</span>
|
<span>{`${count} ${count === 1 ? 'event' : 'events'}`}</span>
|
||||||
|
|||||||
@@ -103,6 +103,22 @@ $item-height: 3.5rem;
|
|||||||
padding-bottom: max(8rem, calc(5rem + env(safe-area-inset-bottom)));
|
padding-bottom: max(8rem, calc(5rem + env(safe-area-inset-bottom)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* a subscribed group and its subscribed events, bounds the sticky header to its own section.
|
||||||
|
flex rather than block so that the row margins do not collapse, keeping the spacing between
|
||||||
|
rows of a section equal to the spacing between sections */
|
||||||
|
.sub-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* scoped to the section, the select view shows the same cards in a flat list */
|
||||||
|
.sub-section .sub--group {
|
||||||
|
position: sticky;
|
||||||
|
/* sticky insets constrain the margin box, negate the row margin so the header sits flush */
|
||||||
|
top: -2px;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
/* ====================== LIST-ITEM ======================*/
|
/* ====================== LIST-ITEM ======================*/
|
||||||
.sub {
|
.sub {
|
||||||
margin: 2px;
|
margin: 2px;
|
||||||
@@ -196,9 +212,15 @@ $item-height: 3.5rem;
|
|||||||
|
|
||||||
.sub--group {
|
.sub--group {
|
||||||
box-shadow: inset 0 0 0 1px var(--user-color, $gray-1325);
|
box-shadow: inset 0 0 0 1px var(--user-color, $gray-1325);
|
||||||
background:
|
/* the card colour is translucent, an opaque base keeps the rows from showing through the sticky header.
|
||||||
|
split into longhands since the shorthand only takes a colour in its last layer */
|
||||||
|
background-color: var(--background-color-override, $viewer-background-color);
|
||||||
|
background-image:
|
||||||
linear-gradient(90deg, color-mix(in srgb, var(--user-color, transparent) 18%, transparent), transparent 42%),
|
linear-gradient(90deg, color-mix(in srgb, var(--user-color, transparent) 18%, transparent), transparent 42%),
|
||||||
var(--card-background-color-override, $viewer-card-bg-color);
|
linear-gradient(
|
||||||
|
var(--card-background-color-override, $viewer-card-bg-color),
|
||||||
|
var(--card-background-color-override, $viewer-card-bg-color)
|
||||||
|
);
|
||||||
|
|
||||||
.sub__binder {
|
.sub__binder {
|
||||||
background: var(--user-color, var(--card-background-color-override, $viewer-card-bg-color));
|
background: var(--user-color, var(--card-background-color-override, $viewer-card-bg-color));
|
||||||
@@ -230,6 +252,16 @@ $item-height: 3.5rem;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* a group holding the running event is only outlined, the green fill belongs to the event row itself */
|
||||||
|
.sub--group.sub--live {
|
||||||
|
box-shadow: inset 0 0 0 2px $active-green;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* loaded but not yet running, subtle enough to not compete with the live ring */
|
||||||
|
.sub--group.sub--armed {
|
||||||
|
box-shadow: inset 0 0 0 2px $gray-1000;
|
||||||
|
}
|
||||||
|
|
||||||
.sub__title {
|
.sub__title {
|
||||||
grid-area: title;
|
grid-area: title;
|
||||||
padding-bottom: 0.5rem;
|
padding-bottom: 0.5rem;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { MaybeNumber, OntimeEvent } from 'ontime-types';
|
import { MaybeNumber, OntimeEvent } from 'ontime-types';
|
||||||
import { dayInMs } from 'ontime-utils';
|
import { dayInMs } from 'ontime-utils';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { IoPencil } from 'react-icons/io5';
|
import { IoPencil } from 'react-icons/io5';
|
||||||
|
|
||||||
import Button from '../../common/components/buttons/Button';
|
import Button from '../../common/components/buttons/Button';
|
||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
CountdownTarget,
|
CountdownTarget,
|
||||||
extendEventData,
|
extendEventData,
|
||||||
getIsLive,
|
getIsLive,
|
||||||
|
groupSubscriptionTargets,
|
||||||
isOutsideRange,
|
isOutsideRange,
|
||||||
preferredFormat12,
|
preferredFormat12,
|
||||||
preferredFormat24,
|
preferredFormat24,
|
||||||
@@ -48,11 +49,25 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
|
|||||||
const [lockAutoScroll, setLockAutoScroll] = useState(false);
|
const [lockAutoScroll, setLockAutoScroll] = useState(false);
|
||||||
const selectedRef = useRef<HTMLDivElement | null>(null);
|
const selectedRef = useRef<HTMLDivElement | null>(null);
|
||||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const stickyHeaderRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
const sections = useMemo(() => groupSubscriptionTargets(subscribedEvents), [subscribedEvents]);
|
||||||
|
|
||||||
|
// The group of the running event is pinned to the top of the list, the running event needs to clear it.
|
||||||
|
// This is measured rather than estimated: the card scales with the viewport (the font sizes are clamped
|
||||||
|
// against vw), it grows while live since the running event's title is added, and long titles wrap.
|
||||||
|
// Measured between 91px and 171px, so a constant would leave the running event behind the header.
|
||||||
|
const getStickyOffset = useCallback(() => {
|
||||||
|
const header = stickyHeaderRef.current;
|
||||||
|
// account for the row margin, so that the rows do not touch
|
||||||
|
return header ? header.offsetHeight + 2 : 0;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const scrollToComponent = useFollowComponent({
|
const scrollToComponent = useFollowComponent({
|
||||||
followRef: selectedRef,
|
followRef: selectedRef,
|
||||||
scrollRef,
|
scrollRef,
|
||||||
doFollow: !lockAutoScroll,
|
doFollow: !lockAutoScroll,
|
||||||
topOffset: 0,
|
getTopOffset: getStickyOffset,
|
||||||
followTrigger: selectedEventId,
|
followTrigger: selectedEventId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -79,7 +94,8 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
|
|||||||
const selectedRect = selectedRef.current.getBoundingClientRect();
|
const selectedRect = selectedRef.current.getBoundingClientRect();
|
||||||
const scrollerRect = scrollRef.current.getBoundingClientRect();
|
const scrollerRect = scrollRef.current.getBoundingClientRect();
|
||||||
if (selectedRect && scrollerRect) {
|
if (selectedRect && scrollerRect) {
|
||||||
const distanceFromTop = selectedRect.top - scrollerRect.top;
|
// discount the pinned group header, so that the threshold keeps its meaning when a header is stuck
|
||||||
|
const distanceFromTop = selectedRect.top - scrollerRect.top - getStickyOffset();
|
||||||
const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > 50;
|
const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > 50;
|
||||||
setLockAutoScroll(hasScrolledOutOfThreshold);
|
setLockAutoScroll(hasScrolledOutOfThreshold);
|
||||||
}
|
}
|
||||||
@@ -98,41 +114,63 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='list-container' onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
|
<div className='list-container' onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
|
||||||
{subscribedEvents.map((event) => {
|
{sections.map((section) => {
|
||||||
// while a group is live, surface the running event's title as the secondary line
|
const rows = section.group ? [section.group, ...section.events] : section.events;
|
||||||
const liveTitle = event.isGroup && event.liveEntry ? event.liveEntry.title : undefined;
|
// the running event anchors the scroll, the group header stays pinned above it
|
||||||
const secondaryData = liveTitle ?? getPropertyValue(event, secondarySource);
|
const anchorId = section.events.find((event) => getIsLive(event.id, selectedEventId, playback))?.id ?? null;
|
||||||
const isGroupedEvent = !event.isGroup && Boolean(event.parent);
|
|
||||||
const activeEntryId = event.isGroup ? (event.liveEntry?.id ?? event.targetId) : event.id;
|
|
||||||
// a subscribed group is live when any of its children is the selected/running event
|
|
||||||
const isLive = activeEntryId ? getIsLive(activeEntryId, selectedEventId, playback) : false;
|
|
||||||
const isArmed = !isLive && activeEntryId === selectedEventId;
|
|
||||||
const countdownEvent = extendEventData(event, currentDay, actualStart, plannedStart, offset, mode, reportData);
|
|
||||||
const displayTitle = getPropertyValue(event, mainSource ?? 'title');
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={section.group?.id ?? rows[0].id} className='sub-section'>
|
||||||
key={event.id}
|
{rows.map((event) => {
|
||||||
ref={isLive ? selectedRef : undefined}
|
// while a group is live, surface the running event's title as the secondary line
|
||||||
className={cx([
|
const liveTitle = event.isGroup && event.liveEntry ? event.liveEntry.title : undefined;
|
||||||
'sub',
|
const secondaryData = liveTitle ?? getPropertyValue(event, secondarySource);
|
||||||
isLive && 'sub--live',
|
const isGroupedEvent = !event.isGroup && Boolean(event.parent);
|
||||||
isArmed && 'sub--armed',
|
const activeEntryId = event.isGroup ? (event.liveEntry?.id ?? event.targetId) : event.id;
|
||||||
event.isGroup && 'sub--group',
|
// a subscribed group is live when any of its children is the selected/running event
|
||||||
isGroupedEvent && 'sub--in-group',
|
const isLive = activeEntryId ? getIsLive(activeEntryId, selectedEventId, playback) : false;
|
||||||
])}
|
const isArmed = !isLive && activeEntryId === selectedEventId;
|
||||||
data-testid={event.cue}
|
// only ever hand the ref to a single row, sharing it would null it out on the next commit
|
||||||
>
|
const isAnchor = isLive && (anchorId === null || event.id === anchorId);
|
||||||
<div
|
const rowRef = isAnchor ? selectedRef : event.isGroup && anchorId ? stickyHeaderRef : undefined;
|
||||||
className='sub__binder'
|
const countdownEvent = extendEventData(
|
||||||
style={{ '--user-color': event.colour, '--group-color': event.groupColour ?? 'transparent' }}
|
event,
|
||||||
/>
|
currentDay,
|
||||||
<ScheduleTime event={countdownEvent} showExpected={showExpected} />
|
actualStart,
|
||||||
<SubscriptionStatus event={countdownEvent} />
|
plannedStart,
|
||||||
<div className={cx(['sub__title', !displayTitle && 'subdued'])}>
|
offset,
|
||||||
{event.isGroup && <span className='sub__eyebrow'>Group</span>}
|
mode,
|
||||||
{displayTitle}
|
reportData,
|
||||||
</div>
|
);
|
||||||
{secondaryData && <div className='sub__secondary'>{secondaryData}</div>}
|
const displayTitle = getPropertyValue(event, mainSource ?? 'title');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={event.id}
|
||||||
|
ref={rowRef}
|
||||||
|
className={cx([
|
||||||
|
'sub',
|
||||||
|
isLive && 'sub--live',
|
||||||
|
isArmed && 'sub--armed',
|
||||||
|
event.isGroup && 'sub--group',
|
||||||
|
isGroupedEvent && 'sub--in-group',
|
||||||
|
])}
|
||||||
|
data-testid={event.cue}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className='sub__binder'
|
||||||
|
style={{ '--user-color': event.colour, '--group-color': event.groupColour ?? 'transparent' }}
|
||||||
|
/>
|
||||||
|
<ScheduleTime event={countdownEvent} showExpected={showExpected} />
|
||||||
|
<SubscriptionStatus event={countdownEvent} />
|
||||||
|
<div className={cx(['sub__title', !displayTitle && 'subdued'])}>
|
||||||
|
{event.isGroup && <span className='sub__eyebrow'>Group</span>}
|
||||||
|
{displayTitle}
|
||||||
|
</div>
|
||||||
|
{secondaryData && <div className='sub__secondary'>{secondaryData}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { OntimeEntry, OntimeEvent, OntimeGroup, SupportedEntry } from 'ontime-types';
|
import { OntimeEntry, OntimeEvent, OntimeGroup, SupportedEntry } from 'ontime-types';
|
||||||
|
|
||||||
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
|
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
|
||||||
import { resolveSubscriptionTarget } from './countdown.utils';
|
import { CountdownTarget, groupSubscriptionTargets, resolveSubscriptionTarget } from './countdown.utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal builders for the extended (metadata enriched) entries the countdown view consumes.
|
* Minimal builders for the extended (metadata enriched) entries the countdown view consumes.
|
||||||
@@ -126,3 +126,89 @@ describe('resolveSubscriptionTarget()', () => {
|
|||||||
expect(result?.liveEntry).toBeNull();
|
expect(result?.liveEntry).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('groupSubscriptionTargets()', () => {
|
||||||
|
/**
|
||||||
|
* Resolves a group the same way the view does, so that the tests exercise the real target shape
|
||||||
|
* (a resolved group carries type Event, so the helper cannot rely on the entry type)
|
||||||
|
*/
|
||||||
|
function resolveGroup(group: ExtendedEntry<OntimeGroup>, flat: ExtendedEntry<OntimeEntry>[]): CountdownTarget {
|
||||||
|
const resolved = resolveSubscriptionTarget(group, flat);
|
||||||
|
if (resolved === null) {
|
||||||
|
throw new Error('test setup: group has no playable children');
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('returns no sections for an empty subscription list', () => {
|
||||||
|
expect(groupSubscriptionTargets([])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives each ungrouped event its own section', () => {
|
||||||
|
const e1 = makeEvent({ id: 'e1' });
|
||||||
|
const e2 = makeEvent({ id: 'e2' });
|
||||||
|
|
||||||
|
expect(groupSubscriptionTargets([e1, e2])).toEqual([
|
||||||
|
{ group: null, events: [e1] },
|
||||||
|
{ group: null, events: [e2] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('absorbs the children of a subscribed group into its section', () => {
|
||||||
|
const group = makeGroup({ id: 'g1' });
|
||||||
|
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
|
||||||
|
const c2 = makeEvent({ id: 'c2', parent: 'g1' });
|
||||||
|
const resolved = resolveGroup(group, [group, c1, c2]);
|
||||||
|
|
||||||
|
expect(groupSubscriptionTargets([resolved, c1, c2])).toEqual([{ group: resolved, events: [c1, c2] }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a subscribed group with no subscribed children as an empty section', () => {
|
||||||
|
const group = makeGroup({ id: 'g1' });
|
||||||
|
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
|
||||||
|
const resolved = resolveGroup(group, [group, c1]);
|
||||||
|
|
||||||
|
expect(groupSubscriptionTargets([resolved])).toEqual([{ group: resolved, events: [] }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not absorb an event which belongs to a different group', () => {
|
||||||
|
const group1 = makeGroup({ id: 'g1' });
|
||||||
|
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
|
||||||
|
const group2 = makeGroup({ id: 'g2' });
|
||||||
|
const c2 = makeEvent({ id: 'c2', parent: 'g2' });
|
||||||
|
const flat = [group1, c1, group2, c2];
|
||||||
|
const resolved1 = resolveGroup(group1, flat);
|
||||||
|
const resolved2 = resolveGroup(group2, flat);
|
||||||
|
|
||||||
|
expect(groupSubscriptionTargets([resolved1, c1, resolved2, c2])).toEqual([
|
||||||
|
{ group: resolved1, events: [c1] },
|
||||||
|
{ group: resolved2, events: [c2] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not absorb an event whose parent group is not subscribed', () => {
|
||||||
|
const group1 = makeGroup({ id: 'g1' });
|
||||||
|
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
|
||||||
|
const group2 = makeGroup({ id: 'g2' });
|
||||||
|
const c2 = makeEvent({ id: 'c2', parent: 'g2' });
|
||||||
|
const resolved1 = resolveGroup(group1, [group1, c1, group2, c2]);
|
||||||
|
|
||||||
|
// only the first group is subscribed, so the second group's child stands alone
|
||||||
|
expect(groupSubscriptionTargets([resolved1, c1, c2])).toEqual([
|
||||||
|
{ group: resolved1, events: [c1] },
|
||||||
|
{ group: null, events: [c2] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes a section when an ungrouped event follows a group', () => {
|
||||||
|
const group = makeGroup({ id: 'g1' });
|
||||||
|
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
|
||||||
|
const e1 = makeEvent({ id: 'e1' });
|
||||||
|
const resolved = resolveGroup(group, [group, c1]);
|
||||||
|
|
||||||
|
expect(groupSubscriptionTargets([resolved, c1, e1])).toEqual([
|
||||||
|
{ group: resolved, events: [c1] },
|
||||||
|
{ group: null, events: [e1] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -252,6 +252,44 @@ export function resolveSubscriptionTarget(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A subscribed group along with the subscribed events which belong to it.
|
||||||
|
* Events without a subscribed parent group form their own section with no group.
|
||||||
|
*/
|
||||||
|
export type CountdownSection = {
|
||||||
|
group: CountdownTarget | null;
|
||||||
|
events: CountdownTarget[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Folds the flat, rundown ordered subscription targets into sections.
|
||||||
|
* A group opens a section which absorbs the following targets that declare it as parent,
|
||||||
|
* which allows the group to be rendered as a sticky header for its own events.
|
||||||
|
*/
|
||||||
|
export function groupSubscriptionTargets(targets: CountdownTarget[]): CountdownSection[] {
|
||||||
|
const sections: CountdownSection[] = [];
|
||||||
|
let openSection: CountdownSection | null = null;
|
||||||
|
|
||||||
|
for (const target of targets) {
|
||||||
|
// resolveSubscriptionTarget spreads the first child, so we cannot rely on the entry type here
|
||||||
|
if (target.isGroup) {
|
||||||
|
openSection = { group: target, events: [] };
|
||||||
|
sections.push(openSection);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (openSection?.group && target.parent === openSection.group.id) {
|
||||||
|
openSection.events.push(target);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
openSection = null;
|
||||||
|
sections.push({ group: null, events: [target] });
|
||||||
|
}
|
||||||
|
|
||||||
|
return sections;
|
||||||
|
}
|
||||||
|
|
||||||
export function extendEventData(
|
export function extendEventData(
|
||||||
event: CountdownTarget,
|
event: CountdownTarget,
|
||||||
currentDay: number,
|
currentDay: number,
|
||||||
|
|||||||
@@ -14,8 +14,8 @@
|
|||||||
"main": "src/main.js",
|
"main": "src/main.js",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"electron": "38.2.1",
|
"electron": "38.2.1",
|
||||||
"electron-builder": "26.15.3",
|
"electron-builder": "26.9.1",
|
||||||
"wait-on": "^9.0.0"
|
"wait-on": "^7.2.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev:electron": "wait-on http://localhost:3000 && cross-env NODE_ENV=development electron .",
|
"dev:electron": "wait-on http://localhost:3000 && cross-env NODE_ENV=development electron .",
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
"cookie": "1.0.2",
|
"cookie": "1.0.2",
|
||||||
"cookie-parser": "1.4.7",
|
"cookie-parser": "1.4.7",
|
||||||
"cors": "2.8.6",
|
"cors": "2.8.6",
|
||||||
"dotenv": "^17.0.0",
|
"dotenv": "^16.0.1",
|
||||||
"express": "5.2.1",
|
"express": "5.2.1",
|
||||||
"express-static-gzip": "3.0.1",
|
"express-static-gzip": "3.0.1",
|
||||||
"express-validator": "7.3.2",
|
"express-validator": "7.3.2",
|
||||||
@@ -31,11 +31,11 @@
|
|||||||
"@types/multer": "2.1.0",
|
"@types/multer": "2.1.0",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"@types/ws": "^8.5.10",
|
"@types/ws": "^8.5.10",
|
||||||
"esbuild": "^0.28.0",
|
"esbuild": "^0.24.0",
|
||||||
"ontime-types": "workspace:*",
|
"ontime-types": "workspace:*",
|
||||||
"server-timing": "^3.3.3",
|
"server-timing": "^3.3.3",
|
||||||
"ts-essentials": "catalog:",
|
"ts-essentials": "catalog:",
|
||||||
"tsx": "^4.23.12",
|
"tsx": "^4.19.2",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"vitest": "catalog:"
|
"vitest": "catalog:"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -353,19 +353,19 @@ export function isLoadedPlayable(loadedEventId: EntryId, rundown: Readonly<Rundo
|
|||||||
|
|
||||||
/** List of event properties which do not need the rundown to be regenerated */
|
/** List of event properties which do not need the rundown to be regenerated */
|
||||||
enum RegenerateWhitelist {
|
enum RegenerateWhitelist {
|
||||||
id, // adding it for completeness, users cannot change ID
|
'id', // adding it for completeness, users cannot change ID
|
||||||
type, // adding it for completeness, users cannot change ID
|
'type', // adding it for completeness, users cannot change ID
|
||||||
cue,
|
'cue',
|
||||||
title,
|
'title',
|
||||||
note,
|
'note',
|
||||||
endAction,
|
'endAction',
|
||||||
timerType,
|
'timerType',
|
||||||
countToEnd,
|
'countToEnd',
|
||||||
colour,
|
'colour',
|
||||||
timeWarning,
|
'timeWarning',
|
||||||
timeDanger,
|
'timeDanger',
|
||||||
custom,
|
'custom',
|
||||||
triggers,
|
'triggers',
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -20,9 +20,12 @@ export function createMcpServer(): Server {
|
|||||||
{ capabilities: { tools: {}, prompts: {}, resources: {} } },
|
{ capabilities: { tools: {}, prompts: {}, resources: {} } },
|
||||||
);
|
);
|
||||||
|
|
||||||
server.setRequestHandler(ListToolsRequestSchema, async (): Promise<ListToolsResult> => ({
|
server.setRequestHandler(
|
||||||
tools: TOOL_DEFINITIONS as unknown as ListToolsResult['tools'],
|
ListToolsRequestSchema,
|
||||||
}));
|
async (): Promise<ListToolsResult> => ({
|
||||||
|
tools: TOOL_DEFINITIONS as unknown as ListToolsResult['tools'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
server.setRequestHandler(CallToolRequestSchema, async (request): Promise<CallToolResult> => {
|
server.setRequestHandler(CallToolRequestSchema, async (request): Promise<CallToolResult> => {
|
||||||
const { name, arguments: args = {} } = request.params;
|
const { name, arguments: args = {} } = request.params;
|
||||||
|
|||||||
+5
-5
@@ -41,14 +41,14 @@
|
|||||||
"format:check": "oxfmt --check"
|
"format:check": "oxfmt --check"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.62.1",
|
"@playwright/test": "1.60.0",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"oxfmt": "^0.63.0",
|
"oxfmt": "^0.42.0",
|
||||||
"oxlint": "^1.78.0",
|
"oxlint": "^1.57.0",
|
||||||
"oxlint-tsgolint": "^7.0.2001",
|
"oxlint-tsgolint": "^0.17.4",
|
||||||
"rimraf": "catalog:",
|
"rimraf": "catalog:",
|
||||||
"turbo": "2.10.10",
|
"turbo": "2.8.20",
|
||||||
"typescript": "catalog:"
|
"typescript": "catalog:"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@11.1.2+sha512.415a1cc25974731e75455c1468371be74c5aa5fb7621b50d4056d222451609f11412f23fd602e6169f1e060466641f798597e1be961a10688836a67b16569499",
|
"packageManager": "pnpm@11.1.2+sha512.415a1cc25974731e75455c1468371be74c5aa5fb7621b50d4056d222451609f11412f23fd602e6169f1e060466641f798597e1be961a10688836a67b16569499",
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
"nanoid": "^6.0.0"
|
"nanoid": "^6.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "catalog:",
|
|
||||||
"ontime-types": "workspace:*",
|
"ontime-types": "workspace:*",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"vitest": "catalog:"
|
"vitest": "catalog:"
|
||||||
|
|||||||
Generated
+2092
-1107
File diff suppressed because it is too large
Load Diff
+3
-3
@@ -4,10 +4,10 @@ packages:
|
|||||||
|
|
||||||
catalog:
|
catalog:
|
||||||
'@types/node': 22.19.11
|
'@types/node': 22.19.11
|
||||||
rimraf: 6.1.3
|
rimraf: 6.0.1
|
||||||
ts-essentials: 10.2.1
|
ts-essentials: 10.1.1
|
||||||
typescript: 7.0.2
|
typescript: 7.0.2
|
||||||
vitest: 4.1.10
|
vitest: 4.0.17
|
||||||
|
|
||||||
allowBuilds:
|
allowBuilds:
|
||||||
'@parcel/watcher': true
|
'@parcel/watcher': true
|
||||||
|
|||||||
Reference in New Issue
Block a user