From 459d584b3572616f97f32a0b4c883e2e6093e97b Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Sat, 15 Feb 2025 12:59:06 +0100 Subject: [PATCH] refactor: public design review --- .../common/components/view-logo/ViewLogo.scss | 5 + .../common/components/view-logo/ViewLogo.tsx | 10 +- .../client/src/views/backstage/Backstage.scss | 18 ++- apps/client/src/views/backstage/Backstage.tsx | 2 +- .../src/views/backstage/backstage.options.ts | 44 +----- .../src/views/backstage/backstage.utils.ts | 9 +- .../views/common/schedule/ScheduleContext.tsx | 3 +- .../views/common/schedule/ScheduleItem.tsx | 6 +- .../views/common/schedule/schedule.options.ts | 48 ++++++ apps/client/src/views/public/Public.scss | 139 +++++++++++++++--- apps/client/src/views/public/Public.tsx | 114 +++++++------- .../client/src/views/public/public.options.ts | 57 +++---- apps/client/src/views/public/public.utils.ts | 45 ++++++ 13 files changed, 345 insertions(+), 155 deletions(-) create mode 100644 apps/client/src/common/components/view-logo/ViewLogo.scss create mode 100644 apps/client/src/views/common/schedule/schedule.options.ts create mode 100644 apps/client/src/views/public/public.utils.ts diff --git a/apps/client/src/common/components/view-logo/ViewLogo.scss b/apps/client/src/common/components/view-logo/ViewLogo.scss new file mode 100644 index 000000000..f2b2491a9 --- /dev/null +++ b/apps/client/src/common/components/view-logo/ViewLogo.scss @@ -0,0 +1,5 @@ +.viewLogo { + max-width: 100%; + max-height: 100%; + display: block; +} diff --git a/apps/client/src/common/components/view-logo/ViewLogo.tsx b/apps/client/src/common/components/view-logo/ViewLogo.tsx index 6ddde4593..6ed80ae1f 100644 --- a/apps/client/src/common/components/view-logo/ViewLogo.tsx +++ b/apps/client/src/common/components/view-logo/ViewLogo.tsx @@ -1,5 +1,7 @@ import { projectLogoPath } from '../../api/constants'; +import './ViewLogo.scss'; + interface ViewLogoProps { name: string; className: string; @@ -7,5 +9,11 @@ interface ViewLogoProps { export default function ViewLogo(props: ViewLogoProps) { const { name, className } = props; - return ; + + // we wrap the image in a div to help maintain the aspect ratio + return ( +
+ +
+ ); } diff --git a/apps/client/src/views/backstage/Backstage.scss b/apps/client/src/views/backstage/Backstage.scss index 4955104bc..8f30845e7 100644 --- a/apps/client/src/views/backstage/Backstage.scss +++ b/apps/client/src/views/backstage/Backstage.scss @@ -106,7 +106,6 @@ } .timer-group { - grid-area: timer; border-top: 2px solid var(--background-color-override, $viewer-background-color); margin-top: max(1vh, 16px); padding-top: max(1vh, 16px); @@ -207,14 +206,27 @@ right: 0; } + .schedule-nav-container { + padding-left: 0; + justify-content: center; + } + .schedule-container { - margin-left: 0; + padding-left: 0; flex: 1; } .info { - flex: 1; width: 100%; + text-align: right; + } + + .qr { + display: none; + } + + .info--stretch { + flex: 1; } } } diff --git a/apps/client/src/views/backstage/Backstage.tsx b/apps/client/src/views/backstage/Backstage.tsx index eb89b66c5..08b0c05bf 100644 --- a/apps/client/src/views/backstage/Backstage.tsx +++ b/apps/client/src/views/backstage/Backstage.tsx @@ -171,7 +171,7 @@ export default function Backstage(props: BackstageProps) { {showSchedule && } -
+
{general.backstageUrl && } {general.backstageInfo &&
{general.backstageInfo}
}
diff --git a/apps/client/src/views/backstage/backstage.options.ts b/apps/client/src/views/backstage/backstage.options.ts index ccf4f94e3..a0b2b414b 100644 --- a/apps/client/src/views/backstage/backstage.options.ts +++ b/apps/client/src/views/backstage/backstage.options.ts @@ -8,7 +8,7 @@ import { OptionTitle, } from '../../common/components/view-params-editor/constants'; import { ViewOption } from '../../common/components/view-params-editor/types'; -import { isStringBoolean } from '../../features/viewers/common/viewUtils'; +import { scheduleOptions } from '../common/schedule/schedule.options'; export const getBackstageOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => { const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' }); @@ -29,26 +29,7 @@ export const getBackstageOptions = (timeFormat: string, customFields: CustomFiel }, ], }, - { - title: OptionTitle.Schedule, - collapsible: true, - options: [ - { - id: 'stopCycle', - title: 'Stop cycling through event pages', - description: 'Schedule will not auto-cycle through events', - type: 'boolean', - defaultValue: false, - }, - { - id: 'cycleInterval', - title: 'Cycle interval', - description: 'How long (in seconds) should each schedule page be shown.', - type: 'number', - defaultValue: 10, - }, - ], - }, + scheduleOptions, ]; }; @@ -75,24 +56,3 @@ export function useBackstageOptions(): BackstageOptions { const options = useMemo(() => getOptionsFromParams(searchParams), [searchParams]); return options; } - -type ScheduleOptions = { - cycleInterval: number; - stopCycle: boolean; -}; - -function getScheduleOptionsFromParams(searchParams: URLSearchParams): ScheduleOptions { - return { - cycleInterval: Number(searchParams.get('cycleInterval')) || 10, - stopCycle: isStringBoolean(searchParams.get('stopCycle')), - }; -} - -/** - * Hook exposes the schedule component options - */ -export function useScheduleOptions() { - const [searchParams] = useSearchParams(); - const options = useMemo(() => getScheduleOptionsFromParams(searchParams), [searchParams]); - return options; -} diff --git a/apps/client/src/views/backstage/backstage.utils.ts b/apps/client/src/views/backstage/backstage.utils.ts index 03086b3c5..7e5e5992a 100644 --- a/apps/client/src/views/backstage/backstage.utils.ts +++ b/apps/client/src/views/backstage/backstage.utils.ts @@ -1,5 +1,6 @@ import { MaybeNumber, OntimeEvent, Playback, TimerPhase } from 'ontime-types'; +import { enDash } from '../../common/utils/styleUtils'; import { getPropertyValue } from '../../features/viewers/common/viewUtils'; /** @@ -45,16 +46,16 @@ export function getCardData( } // if we are loaded, we show the upcoming event as next - const nowMain = getPropertyValue(eventNow, mainSource ?? 'title'); + const nowMain = getPropertyValue(eventNow, mainSource ?? 'title') || enDash; const nowSecondary = getPropertyValue(eventNow, secondarySource); - const nextMain = getPropertyValue(eventNext, mainSource ?? 'title'); + const nextMain = getPropertyValue(eventNext, mainSource ?? 'title') || enDash; const nextSecondary = getPropertyValue(eventNext, secondarySource); return { - showNow: Boolean(nowMain) || Boolean(nowSecondary), + showNow: eventNow !== null, nowMain, nowSecondary, - showNext: Boolean(nextMain) || Boolean(nextSecondary), + showNext: eventNext !== null, nextMain, nextSecondary, }; diff --git a/apps/client/src/views/common/schedule/ScheduleContext.tsx b/apps/client/src/views/common/schedule/ScheduleContext.tsx index a690c3d76..b8a79eb80 100644 --- a/apps/client/src/views/common/schedule/ScheduleContext.tsx +++ b/apps/client/src/views/common/schedule/ScheduleContext.tsx @@ -11,7 +11,8 @@ import { import { isOntimeEvent, OntimeEvent, OntimeRundownEntry } from 'ontime-types'; import { usePartialRundown } from '../../../common/hooks-query/useRundown'; -import { useScheduleOptions } from '../../backstage/backstage.options'; + +import { useScheduleOptions } from './schedule.options'; interface ScheduleContextState { events: OntimeEvent[]; diff --git a/apps/client/src/views/common/schedule/ScheduleItem.tsx b/apps/client/src/views/common/schedule/ScheduleItem.tsx index b67c06a99..f2ffb8b2f 100644 --- a/apps/client/src/views/common/schedule/ScheduleItem.tsx +++ b/apps/client/src/views/common/schedule/ScheduleItem.tsx @@ -35,13 +35,13 @@ export default function ScheduleItem(props: ScheduleItemProps) { - {' → '} + → {backstageEvent && '*'} - {' → '} + → {backstageEvent && '*'} @@ -56,7 +56,7 @@ export default function ScheduleItem(props: ScheduleItemProps) {
- {' → '} + → {backstageEvent && '*'}
diff --git a/apps/client/src/views/common/schedule/schedule.options.ts b/apps/client/src/views/common/schedule/schedule.options.ts new file mode 100644 index 000000000..b70fc15b9 --- /dev/null +++ b/apps/client/src/views/common/schedule/schedule.options.ts @@ -0,0 +1,48 @@ +import { useMemo } from 'react'; +import { useSearchParams } from 'react-router-dom'; + +import { OptionTitle } from '../../../common/components/view-params-editor/constants'; +import { ViewOption } from '../../../common/components/view-params-editor/types'; +import { isStringBoolean } from '../../../features/viewers/common/viewUtils'; + +export const scheduleOptions: ViewOption = { + title: OptionTitle.Schedule, + collapsible: true, + options: [ + { + id: 'stopCycle', + title: 'Stop cycling through event pages', + description: 'Schedule will not auto-cycle through events', + type: 'boolean', + defaultValue: false, + }, + { + id: 'cycleInterval', + title: 'Cycle interval', + description: 'How long (in seconds) should each schedule page be shown.', + type: 'number', + defaultValue: 10, + }, + ], +}; + +type ScheduleOptions = { + cycleInterval: number; + stopCycle: boolean; +}; + +function getScheduleOptionsFromParams(searchParams: URLSearchParams): ScheduleOptions { + return { + cycleInterval: Number(searchParams.get('cycleInterval')) || 10, + stopCycle: isStringBoolean(searchParams.get('stopCycle')), + }; +} + +/** + * Hook exposes the schedule component options + */ +export function useScheduleOptions() { + const [searchParams] = useSearchParams(); + const options = useMemo(() => getScheduleOptionsFromParams(searchParams), [searchParams]); + return options; +} diff --git a/apps/client/src/views/public/Public.scss b/apps/client/src/views/public/Public.scss index e4c577677..9c306b604 100644 --- a/apps/client/src/views/public/Public.scss +++ b/apps/client/src/views/public/Public.scss @@ -10,23 +10,30 @@ font-family: var(--font-family-override, $viewer-font-family); background: var(--background-color-override, $viewer-background-color); color: var(--color-override, $viewer-color); - gap: min(2vh, 16px); - padding: min(2vh, 16px) clamp(16px, 10vw, 64px); + gap: $view-element-gap; + padding: $view-outer-padding; display: grid; - grid-template-columns: 1fr 1fr 40vw; + grid-template-columns: 1fr 40vw; grid-template-rows: auto 12px 1fr auto; grid-template-areas: - ' header header header' - ' progress progress schedule-nav' - ' now now schedule' - ' info info schedule'; + 'header header' + 'now schedule-nav' + 'info schedule'; + + .empty-container { + position: absolute; + top: 0; + left: 0; + right: 0; + margin-top: 25vh; + } /* =================== HEADER + EXTRAS ===================*/ .project-header { grid-area: header; - font-size: clamp(32px, 4.5vw, 64px); + font-size: $header-font-size; font-weight: 600; display: flex; gap: 1rem; @@ -37,18 +44,22 @@ max-height: min(100px, 20vh); } + .title { + line-height: 1.1em; + } + .clock-container { margin-left: auto; + font-weight: 600; .label { - font-size: clamp(16px, 1.5vw, 24px); - font-weight: 600; + font-size: $timer-label-size; color: var(--label-color-override, $viewer-label-color); text-transform: uppercase; } .time { - font-size: clamp(32px, 3.5vw, 50px); + font-size: $timer-value-size; color: var(--secondary-color-override, $viewer-secondary-color); letter-spacing: 0.05em; line-height: 0.95em; @@ -57,50 +68,136 @@ /* =================== MAIN - NOW ===================*/ - .now-container { + .card-container { grid-area: now; display: flex; flex-direction: column; - gap: min(2vh, 16px); + gap: $view-element-gap; } .event { background-color: var(--card-background-color-override, $viewer-card-bg-color); - padding: 16px 24px; - border-radius: 8px; + padding: $view-card-padding; + border-radius: $element-border-radius; + } + + .timer-group { + border-top: 2px solid var(--background-color-override, $viewer-background-color); + margin-top: max(1vh, 16px); + padding-top: max(1vh, 16px); + display: flex; + row-gap: 0.5em; + } + + .aux-timers { + &__label { + font-size: $timer-label-size; + color: var(--label-color-override, $viewer-label-color); + font-weight: 600; + text-transform: uppercase; + } + + &__value { + font-size: $base-font-size; + color: var(--secondary-color-override, $viewer-secondary-color); + letter-spacing: 0.05em; + line-height: 0.95em; + } + + &--pending { + color: var(--timer-pending-color-override, $ontime-roll); + } } /* =================== MAIN - SCHEDULE ===================*/ + $schedule-left-spacing: clamp(16px, 4vw, 64px); .schedule-container { grid-area: schedule; overflow: hidden; height: 100%; - margin-left: clamp(16px, 5vw, 64px); + padding-left: $schedule-left-spacing; } .schedule-nav-container { grid-area: schedule-nav; - align-self: center; + padding-left: $schedule-left-spacing; } + /* =================== MAIN - INFO ===================*/ + .info { grid-area: info; display: flex; gap: max(1vw, 16px); + align-self: flex-end; overflow: hidden; + align-items: end; &__message { - font-size: clamp(16px, 1.5vw, 24px); - line-height: 1.3em; + font-size: $base-font-size; + line-height: 1.2em; white-space: pre-line; overflow: hidden; flex: 1; } .qr { - padding: 4px; - background-color: white; + padding: 0.5rem; + background-color: $ui-white; + border-radius: 2px; + } + } +} + +/* =================== MOBILE ===================*/ +@media screen and (max-width: 768px) { + .public-screen { + display: flex; + flex-direction: column; + overflow-y: auto; + + .project-header { + flex-direction: column; + position: relative; + gap: 0.5rem; + } + + .logo { + height: min(50px, 10vh); + } + + .timer-group { + flex-wrap: wrap; + } + + .clock-container { + position: absolute; + top: 0; + right: 0; + } + + .schedule-nav-container { + padding-left: 0; + justify-content: center; + } + + .schedule-container { + padding-left: 0; + flex: 1; + } + + .info { + width: 100%; + text-align: right; + } + + .qr { + display: none; + } + + .info--stretch { + flex: 1; } } } diff --git a/apps/client/src/views/public/Public.tsx b/apps/client/src/views/public/Public.tsx index 3f174100a..d7e4b59a4 100644 --- a/apps/client/src/views/public/Public.tsx +++ b/apps/client/src/views/public/Public.tsx @@ -1,28 +1,28 @@ import QRCode from 'react-qr-code'; -import { useSearchParams } from 'react-router-dom'; -import { AnimatePresence, motion } from 'framer-motion'; +import { useViewportSize } from '@mantine/hooks'; import { CustomFields, OntimeEvent, ProjectData, Settings } from 'ontime-types'; +import Empty from '../../common/components/state/Empty'; import TitleCard from '../../common/components/title-card/TitleCard'; import ViewLogo from '../../common/components/view-logo/ViewLogo'; import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor'; import { useWindowTitle } from '../../common/hooks/useWindowTitle'; import { ViewExtendedTimer } from '../../common/models/TimeManager.type'; +import { cx } from '../../common/utils/styleUtils'; import { formatTime, getDefaultFormat } from '../../common/utils/time'; import SuperscriptTime from '../../features/viewers/common/superscript-time/SuperscriptTime'; -import { getPropertyValue } from '../../features/viewers/common/viewUtils'; import { useTranslation } from '../../translation/TranslationProvider'; +import { getIsPendingStart } from '../backstage/backstage.utils'; import PublicSchedule from '../common/schedule/PublicSchedule'; -import { titleVariants } from '../timer/timer.animations'; -import { getPublicOptions } from './public.options'; +import { getPublicOptions, usePublicOptions } from './public.options'; +import { getCardData, getFirstStartTime } from './public.utils'; import './Public.scss'; -export const MotionTitleCard = motion(TitleCard); - interface BackstageProps { customFields: CustomFields; + events: OntimeEvent[]; general: ProjectData; isMirrored: boolean; publicEventNow: OntimeEvent | null; @@ -33,73 +33,85 @@ interface BackstageProps { } export default function Public(props: BackstageProps) { - const { customFields, general, isMirrored, publicEventNow, publicEventNext, time, publicSelectedId, settings } = - props; + const { + customFields, + events, + general, + isMirrored, + publicEventNow, + publicEventNext, + time, + publicSelectedId, + settings, + } = props; const { getLocalizedString } = useTranslation(); - const [searchParams] = useSearchParams(); + const { secondarySource } = usePublicOptions(); + const { height: screenHeight } = useViewportSize(); useWindowTitle('Public Schedule'); - const clock = formatTime(time.clock); - const qrSize = Math.max(window.innerWidth / 15, 128); + // gather card data + const { showNow, nowMain, nowSecondary, showNext, nextMain, nextSecondary } = getCardData( + publicEventNow, + publicEventNext, + 'title', + secondarySource, + time.playback, + ); + // gather timer data + const clock = formatTime(time.clock); + const isPendingStart = getIsPendingStart(time.playback, time.phase); + const scheduledStart = showNow ? '' : getFirstStartTime(events[0]); + + // gather presentation styles + const qrSize = Math.max(window.innerWidth / 15, 72); + const showSchedule = screenHeight > 700; // in vertical screens we may not have space + + // gather option data const defaultFormat = getDefaultFormat(settings?.timeFormat); const publicOptions = getPublicOptions(defaultFormat, customFields); - const secondarySource = searchParams.get('secondary-src'); - const secondaryTextNext = getPropertyValue(publicEventNext, secondarySource); - const secondaryTextNow = getPropertyValue(publicEventNow, secondarySource); - return (
- {general?.projectLogo && } - {general.title} + {general?.projectLogo ? :
} +
{general.title}
{getLocalizedString('common.time_now')}
-
- - {publicEventNow && ( - - )} - + {events.length === 0 && ( +
+ +
+ )} - - {publicEventNext && ( - - )} - +
+ {showNow && } + {!showNow && scheduledStart && ( +
+
{getLocalizedString('countdown.waiting')}
+
+
+
+ {getLocalizedString('common.scheduled_start')} +
+ +
+
+
+ )} + {showNext && }
- + {showSchedule && } -
+
{general.publicUrl && } {general.publicInfo &&
{general.publicInfo}
}
diff --git a/apps/client/src/views/public/public.options.ts b/apps/client/src/views/public/public.options.ts index 210abf12e..821de86c1 100644 --- a/apps/client/src/views/public/public.options.ts +++ b/apps/client/src/views/public/public.options.ts @@ -1,4 +1,6 @@ -import { CustomFields } from 'ontime-types'; +import { useMemo } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { CustomFields, OntimeEvent } from 'ontime-types'; import { getTimeOption, @@ -6,6 +8,7 @@ import { OptionTitle, } from '../../common/components/view-params-editor/constants'; import { ViewOption } from '../../common/components/view-params-editor/types'; +import { scheduleOptions } from '../common/schedule/schedule.options'; export const getPublicOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => { const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' }); @@ -26,32 +29,30 @@ export const getPublicOptions = (timeFormat: string, customFields: CustomFields) }, ], }, - { - title: OptionTitle.Schedule, - collapsible: true, - options: [ - { - id: 'eventsPerPage', - title: 'Events per page', - description: 'Sets the number of events on the page, can cause overflow', - type: 'number', - placeholder: '8 (default)', - }, - { - id: 'hidePast', - title: 'Hide past events', - description: 'Scheduler will only show upcoming events', - type: 'boolean', - defaultValue: false, - }, - { - id: 'stopCycle', - title: 'Stop cycling through event pages', - description: 'Schedule will not auto-cycle through events', - type: 'boolean', - defaultValue: false, - }, - ], - }, + scheduleOptions, ]; }; + +type PublicOptions = { + secondarySource: keyof OntimeEvent | null; +}; + +/** + * Utility extract the view options from URL Params + * the names and fallback are manually matched with timerOptions + */ +function getOptionsFromParams(searchParams: URLSearchParams): PublicOptions { + // we manually make an object that matches the key above + return { + secondarySource: searchParams.get('secondary-src') as keyof OntimeEvent | null, + }; +} + +/** + * Hook exposes the backstage view options + */ +export function usePublicOptions(): PublicOptions { + const [searchParams] = useSearchParams(); + const options = useMemo(() => getOptionsFromParams(searchParams), [searchParams]); + return options; +} diff --git a/apps/client/src/views/public/public.utils.ts b/apps/client/src/views/public/public.utils.ts new file mode 100644 index 000000000..a0b463b8b --- /dev/null +++ b/apps/client/src/views/public/public.utils.ts @@ -0,0 +1,45 @@ +import { OntimeEvent, Playback } from 'ontime-types'; + +import { enDash } from '../../common/utils/styleUtils'; +import { getPropertyValue } from '../../features/viewers/common/viewUtils'; + +/** + * What should we be showing in the cards? + */ +export function getCardData( + eventNow: OntimeEvent | null, + eventNext: OntimeEvent | null, + mainSource: keyof OntimeEvent | null, + secondarySource: keyof OntimeEvent | null, + playback: Playback, +) { + if (playback === Playback.Stop) { + return { + showNow: false, + nowMain: undefined, + nowSecondary: undefined, + showNext: false, + nextMain: undefined, + nextSecondary: undefined, + }; + } + + // if we are loaded, we show the upcoming event as next + const nowMain = getPropertyValue(eventNow, mainSource ?? 'title') || enDash; + const nowSecondary = getPropertyValue(eventNow, secondarySource); + const nextMain = getPropertyValue(eventNext, mainSource ?? 'title') || enDash; + const nextSecondary = getPropertyValue(eventNext, secondarySource); + + return { + showNow: eventNow !== null, + nowMain, + nowSecondary, + showNext: eventNext !== null, + nextMain, + nextSecondary, + }; +} + +export function getFirstStartTime(firstPublicEvent: OntimeEvent | null): number | undefined { + return firstPublicEvent?.timeStart; +}