mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-22 15:39:11 +00:00
feat: UI access for custom views (#2020)
* style: consistent casing on menu * refactor: bundle demo from code * feat: allow uploading custom views
This commit is contained in:
@@ -38,6 +38,7 @@ dist/
|
|||||||
# bundled assets
|
# bundled assets
|
||||||
translations.json
|
translations.json
|
||||||
override.css
|
override.css
|
||||||
|
**/external/demo/index.html
|
||||||
|
|
||||||
# working stuff
|
# working stuff
|
||||||
**/TODO.md
|
**/TODO.md
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export const APP_SERVER_PORT = ['appServerPort'];
|
|||||||
export const APP_VERSION = ['appVersion'];
|
export const APP_VERSION = ['appVersion'];
|
||||||
export const AUTOMATION = ['automation'];
|
export const AUTOMATION = ['automation'];
|
||||||
export const CUSTOM_FIELDS = ['customFields'];
|
export const CUSTOM_FIELDS = ['customFields'];
|
||||||
|
export const CUSTOM_VIEWS = ['customViews'];
|
||||||
export const PROJECT_DATA = ['project'];
|
export const PROJECT_DATA = ['project'];
|
||||||
export const PROJECT_LIST = ['projectList'];
|
export const PROJECT_LIST = ['projectList'];
|
||||||
export const PROJECT_RUNDOWNS = ['projectRundowns'];
|
export const PROJECT_RUNDOWNS = ['projectRundowns'];
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import { type CustomViewsListResponse, type MessageResponse } from 'ontime-types';
|
||||||
|
|
||||||
|
import { apiEntryUrl } from './constants';
|
||||||
|
import type { RequestOptions } from './requestOptions';
|
||||||
|
import { axiosConfig } from './requestTimeouts';
|
||||||
|
import { downloadBlob } from './utils';
|
||||||
|
|
||||||
|
const customViewsPath = `${apiEntryUrl}/custom-views`;
|
||||||
|
|
||||||
|
export async function getCustomViews(options?: RequestOptions): Promise<CustomViewsListResponse> {
|
||||||
|
const response =
|
||||||
|
await axios.get<CustomViewsListResponse>(customViewsPath, {
|
||||||
|
signal: options?.signal,
|
||||||
|
timeout: options?.timeout ?? axiosConfig.shortTimeout,
|
||||||
|
})
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadCustomView(slug: string, file: File, options?: RequestOptions): Promise<MessageResponse> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('indexHtml', file);
|
||||||
|
|
||||||
|
return (
|
||||||
|
await axios.post<MessageResponse>(`${customViewsPath}/${encodeURIComponent(slug)}/upload`, formData, {
|
||||||
|
signal: options?.signal,
|
||||||
|
timeout: options?.timeout ?? axiosConfig.longTimeout,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadCustomView(slug: string, options?: RequestOptions): Promise<void> {
|
||||||
|
const response = await axios.get(`${customViewsPath}/${encodeURIComponent(slug)}/download`, {
|
||||||
|
signal: options?.signal,
|
||||||
|
timeout: options?.timeout ?? axiosConfig.longTimeout,
|
||||||
|
responseType: 'blob',
|
||||||
|
});
|
||||||
|
|
||||||
|
downloadBlob(response.data, `${slug}-index.html`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restoreDemoView(options?: RequestOptions): Promise<MessageResponse> {
|
||||||
|
return (
|
||||||
|
await axios.post<MessageResponse>(`${customViewsPath}/restore-demo`, null, {
|
||||||
|
signal: options?.signal,
|
||||||
|
timeout: options?.timeout ?? axiosConfig.longTimeout,
|
||||||
|
})
|
||||||
|
).data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCustomView(slug: string, options?: RequestOptions): Promise<void> {
|
||||||
|
await axios.delete(`${customViewsPath}/${encodeURIComponent(slug)}`, {
|
||||||
|
signal: options?.signal,
|
||||||
|
timeout: options?.timeout ?? axiosConfig.longTimeout,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { type CustomViewsListResponse } from 'ontime-types';
|
||||||
|
|
||||||
|
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||||
|
import { CUSTOM_VIEWS } from '../api/constants';
|
||||||
|
import { getCustomViews } from '../api/customViews';
|
||||||
|
|
||||||
|
const placeholderCustomViews: CustomViewsListResponse = {
|
||||||
|
views: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function useCustomViews() {
|
||||||
|
const { data, status, refetch } = useQuery({
|
||||||
|
queryKey: CUSTOM_VIEWS,
|
||||||
|
queryFn: ({ signal }) => getCustomViews({ signal }),
|
||||||
|
placeholderData: (previousData, _previousQuery) => previousData,
|
||||||
|
refetchInterval: queryRefetchIntervalSlow,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { data: data ?? placeholderCustomViews, status, refetch };
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { ChangeEvent, FormEvent, useMemo, useRef, useState } from 'react';
|
||||||
|
import { IoCloudUploadOutline } from 'react-icons/io5';
|
||||||
|
|
||||||
|
import { uploadCustomView } from '../../../../common/api/customViews';
|
||||||
|
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||||
|
import Button from '../../../../common/components/buttons/Button';
|
||||||
|
import Input from '../../../../common/components/input/input/Input';
|
||||||
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
import { getFileError, getSlugError, getViewUrl, maxUploadLabel } from './customViews.utils';
|
||||||
|
|
||||||
|
import style from './CustomViews.module.scss';
|
||||||
|
|
||||||
|
interface CustomViewFormProps {
|
||||||
|
onComplete: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CustomViewForm({ onComplete, onClose }: CustomViewFormProps) {
|
||||||
|
const [slug, setSlug] = useState('');
|
||||||
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
|
const [slugDirty, setSlugDirty] = useState(false);
|
||||||
|
const [fileDirty, setFileDirty] = useState(false);
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const normalisedSlug = useMemo(() => slug.trim().toLowerCase(), [slug]);
|
||||||
|
const previewUrl = getViewUrl(normalisedSlug);
|
||||||
|
const slugError = useMemo(() => getSlugError(normalisedSlug), [normalisedSlug]);
|
||||||
|
const fileError = useMemo(() => getFileError(selectedFile), [selectedFile]);
|
||||||
|
const canUpload = Boolean(normalisedSlug && selectedFile) && !slugError && !fileError && !isUploading;
|
||||||
|
|
||||||
|
const handleSelectFile = (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setSelectedFile(event.target.files?.[0] ?? null);
|
||||||
|
setFileDirty(true);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpload = async (event: FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!selectedFile || slugError || fileError) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsUploading(true);
|
||||||
|
setError(null);
|
||||||
|
await uploadCustomView(normalisedSlug, selectedFile);
|
||||||
|
onComplete();
|
||||||
|
} catch (err) {
|
||||||
|
setError(maybeAxiosError(err));
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel.Indent as='form' onSubmit={handleUpload} className={style.uploadForm}>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
type='file'
|
||||||
|
onChange={handleSelectFile}
|
||||||
|
accept='.html,text/html'
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={style.step}>
|
||||||
|
<div className={style.stepTitle}>1. Choose a name</div>
|
||||||
|
<Panel.Description>Name</Panel.Description>
|
||||||
|
<Input
|
||||||
|
value={slug}
|
||||||
|
onChange={(event) => {
|
||||||
|
setSlug(event.target.value);
|
||||||
|
setSlugDirty(true);
|
||||||
|
}}
|
||||||
|
placeholder='my-view'
|
||||||
|
aria-label='Custom view name'
|
||||||
|
autoCapitalize='off'
|
||||||
|
autoComplete='off'
|
||||||
|
fluid
|
||||||
|
/>
|
||||||
|
<Panel.Description>
|
||||||
|
Use lowercase letters, numbers, and dashes. Example: <Panel.Highlight>my-view</Panel.Highlight>
|
||||||
|
</Panel.Description>
|
||||||
|
<Panel.Description>
|
||||||
|
Preview URL: <Panel.Highlight>{previewUrl}</Panel.Highlight>
|
||||||
|
</Panel.Description>
|
||||||
|
{slugDirty && slugError && <Panel.Error>{slugError}</Panel.Error>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={style.step}>
|
||||||
|
<div className={style.stepTitle}>2. Select index.html</div>
|
||||||
|
<Panel.Description>Upload file</Panel.Description>
|
||||||
|
<Panel.InlineElements wrap='wrap' className={style.filePicker}>
|
||||||
|
<Button onClick={() => fileInputRef.current?.click()}>
|
||||||
|
{selectedFile ? 'Replace index.html' : 'Choose index.html'}
|
||||||
|
</Button>
|
||||||
|
<span className={style.fileName}>
|
||||||
|
{selectedFile ? `${selectedFile.name} (${Math.ceil(selectedFile.size / 1024)} KB)` : 'No file selected'}
|
||||||
|
</span>
|
||||||
|
</Panel.InlineElements>
|
||||||
|
<Panel.Description>Accepted: index.html only, maximum {maxUploadLabel}.</Panel.Description>
|
||||||
|
{fileDirty && fileError && <Panel.Error>{fileError}</Panel.Error>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <Panel.Error>{error}</Panel.Error>}
|
||||||
|
|
||||||
|
<Panel.InlineElements align='end'>
|
||||||
|
<Button onClick={onClose}>Cancel</Button>
|
||||||
|
<Button variant='primary' type='submit' loading={isUploading} disabled={!canUpload}>
|
||||||
|
Upload view <IoCloudUploadOutline />
|
||||||
|
</Button>
|
||||||
|
</Panel.InlineElements>
|
||||||
|
</Panel.Indent>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
.uploadForm {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepTitle {
|
||||||
|
font-weight: 600;
|
||||||
|
color: $gray-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filePicker {
|
||||||
|
min-height: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileName {
|
||||||
|
color: $gray-300;
|
||||||
|
font-size: calc(1rem - 2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.missing {
|
||||||
|
color: $warning-orange;
|
||||||
|
}
|
||||||
|
|
||||||
|
.urlCell {
|
||||||
|
font-size: calc(1rem - 2px);
|
||||||
|
color: $gray-300;
|
||||||
|
user-select: all;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionsHeader {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionsCell {
|
||||||
|
width: 1%;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionsGroup {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { IoAdd } from 'react-icons/io5';
|
||||||
|
|
||||||
|
import { restoreDemoView } from '../../../../common/api/customViews';
|
||||||
|
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||||
|
import Button from '../../../../common/components/buttons/Button';
|
||||||
|
import Info from '../../../../common/components/info/Info';
|
||||||
|
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
|
||||||
|
import useCustomViews from '../../../../common/hooks-query/useCustomViews';
|
||||||
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
import CustomViewForm from './CustomViewForm';
|
||||||
|
import CustomViewsList from './CustomViewsList';
|
||||||
|
import { customViewsDocs } from './customViews.utils';
|
||||||
|
|
||||||
|
export default function CustomViews() {
|
||||||
|
const { data, refetch, status } = useCustomViews();
|
||||||
|
const [isUploadOpen, setIsUploadOpen] = useState(false);
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const hasDemoView = data.views.some((view) => view.slug === 'demo');
|
||||||
|
|
||||||
|
const handleUploadComplete = async () => {
|
||||||
|
setIsUploadOpen(false);
|
||||||
|
await refetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRestoreDemo = async () => {
|
||||||
|
try {
|
||||||
|
setActionError(null);
|
||||||
|
await restoreDemoView();
|
||||||
|
await refetch();
|
||||||
|
} catch (error) {
|
||||||
|
setActionError(maybeAxiosError(error));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel.Section>
|
||||||
|
<Panel.Card>
|
||||||
|
<Panel.SubHeader>
|
||||||
|
Custom views
|
||||||
|
<Panel.InlineElements>
|
||||||
|
<Button variant='ghosted' onClick={handleRestoreDemo} disabled={hasDemoView}>
|
||||||
|
Restore demo
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setIsUploadOpen(true)} disabled={isUploadOpen}>
|
||||||
|
New <IoAdd />
|
||||||
|
</Button>
|
||||||
|
</Panel.InlineElements>
|
||||||
|
</Panel.SubHeader>
|
||||||
|
<Panel.Divider />
|
||||||
|
|
||||||
|
<Panel.Section>
|
||||||
|
<Info>
|
||||||
|
Upload one <strong>index.html</strong> per view to <strong>/external/<name>/</strong>.
|
||||||
|
<br />
|
||||||
|
External imports are not allowed, include all assets inside the html file.
|
||||||
|
<ExternalLink href={customViewsDocs}>See the docs</ExternalLink>
|
||||||
|
</Info>
|
||||||
|
</Panel.Section>
|
||||||
|
|
||||||
|
<Panel.Section>
|
||||||
|
<Panel.Loader isLoading={status === 'pending'} />
|
||||||
|
|
||||||
|
{isUploadOpen && <CustomViewForm onComplete={handleUploadComplete} onClose={() => setIsUploadOpen(false)} />}
|
||||||
|
|
||||||
|
{actionError && <Panel.Error>{actionError}</Panel.Error>}
|
||||||
|
|
||||||
|
<CustomViewsList
|
||||||
|
views={data.views}
|
||||||
|
onOpenUpload={() => setIsUploadOpen(true)}
|
||||||
|
onMutate={() => refetch()}
|
||||||
|
onError={setActionError}
|
||||||
|
/>
|
||||||
|
|
||||||
|
</Panel.Section>
|
||||||
|
</Panel.Card>
|
||||||
|
</Panel.Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { CustomViewSummary } from 'ontime-types';
|
||||||
|
|
||||||
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
import CustomViewsListItem from './CustomViewsListItem';
|
||||||
|
|
||||||
|
import style from './CustomViews.module.scss';
|
||||||
|
|
||||||
|
interface CustomViewsListProps {
|
||||||
|
views: CustomViewSummary[];
|
||||||
|
onOpenUpload: () => void;
|
||||||
|
onMutate: () => void;
|
||||||
|
onError: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CustomViewsList({ views, onOpenUpload, onMutate, onError }: CustomViewsListProps) {
|
||||||
|
return (
|
||||||
|
<Panel.Table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>URL</th>
|
||||||
|
<th className={style.actionsHeader} />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{views.length === 0 && <Panel.TableEmpty handleClick={onOpenUpload} label='No custom views yet' />}
|
||||||
|
{views.map((view, index) => (
|
||||||
|
<CustomViewsListItem
|
||||||
|
key={view.slug}
|
||||||
|
slug={view.slug}
|
||||||
|
index={index}
|
||||||
|
onMutate={onMutate}
|
||||||
|
onError={onError}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</Panel.Table>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { IoDownloadOutline, IoOpenOutline, IoTrash } from 'react-icons/io5';
|
||||||
|
|
||||||
|
import { deleteCustomView, downloadCustomView } from '../../../../common/api/customViews';
|
||||||
|
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||||
|
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||||
|
import { handleLinks } from '../../../../common/utils/linkUtils';
|
||||||
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
import { getViewUrl } from './customViews.utils';
|
||||||
|
|
||||||
|
import style from './CustomViews.module.scss';
|
||||||
|
|
||||||
|
interface CustomViewsListItemProps {
|
||||||
|
slug: string;
|
||||||
|
index: number;
|
||||||
|
onMutate: () => void;
|
||||||
|
onError: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CustomViewsListItem({ slug, index, onMutate, onError }: CustomViewsListItemProps) {
|
||||||
|
const handlePreview = () => {
|
||||||
|
handleLinks(`external/${encodeURIComponent(slug)}/`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = async () => {
|
||||||
|
try {
|
||||||
|
await downloadCustomView(slug);
|
||||||
|
} catch (error) {
|
||||||
|
onError(maybeAxiosError(error));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
try {
|
||||||
|
await deleteCustomView(slug);
|
||||||
|
onMutate();
|
||||||
|
} catch (error) {
|
||||||
|
onError(maybeAxiosError(error));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td>{slug}</td>
|
||||||
|
<td className={style.urlCell}>{getViewUrl(slug)}</td>
|
||||||
|
<td className={style.actionsCell}>
|
||||||
|
<Panel.InlineElements relation='inner' align='end' className={style.actionsGroup}>
|
||||||
|
<IconButton
|
||||||
|
variant='ghosted-white'
|
||||||
|
onClick={handlePreview}
|
||||||
|
aria-label='Preview custom view'
|
||||||
|
data-testid={`custom-view__preview_${index}`}
|
||||||
|
>
|
||||||
|
<IoOpenOutline />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
variant='ghosted-white'
|
||||||
|
onClick={handleDownload}
|
||||||
|
aria-label='Download custom view'
|
||||||
|
data-testid={`custom-view__download_${index}`}
|
||||||
|
>
|
||||||
|
<IoDownloadOutline />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
variant='ghosted-destructive'
|
||||||
|
onClick={handleDelete}
|
||||||
|
aria-label='Delete custom view'
|
||||||
|
data-testid={`custom-view__delete_${index}`}
|
||||||
|
>
|
||||||
|
<IoTrash />
|
||||||
|
</IconButton>
|
||||||
|
</Panel.InlineElements>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { baseURI, serverURL } from '../../../../externals';
|
||||||
|
|
||||||
|
export const customViewsDocs = 'https://docs.getontime.no/features/custom-views/';
|
||||||
|
const customViewSlugPattern = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
||||||
|
const maxUploadBytes = 4_000_000;
|
||||||
|
export const maxUploadLabel = `${maxUploadBytes / 1_000_000}MB`;
|
||||||
|
|
||||||
|
export function getSlugError(slug: string): string | null {
|
||||||
|
if (!slug) {
|
||||||
|
return 'Name is required.';
|
||||||
|
}
|
||||||
|
if (!customViewSlugPattern.test(slug)) {
|
||||||
|
return 'Use lowercase letters, numbers, and dashes only.';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFileError(file: File | null): string | null {
|
||||||
|
if (!file) {
|
||||||
|
return 'index.html is required.';
|
||||||
|
}
|
||||||
|
if (file.name.toLowerCase() !== 'index.html') {
|
||||||
|
return 'Only index.html uploads are supported.';
|
||||||
|
}
|
||||||
|
if (file.size === 0) {
|
||||||
|
return 'Uploaded file is empty.';
|
||||||
|
}
|
||||||
|
if (file.size > maxUploadBytes) {
|
||||||
|
return `File size limit (${maxUploadLabel}) exceeded.`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getViewUrl(slug: string): string {
|
||||||
|
const url = new URL(serverURL);
|
||||||
|
const path = slug ? `external/${encodeURIComponent(slug)}/` : 'external/';
|
||||||
|
url.pathname = baseURI ? `${baseURI}/${path}` : `/${path}`;
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
|||||||
import { isDocker } from '../../../../externals';
|
import { isDocker } from '../../../../externals';
|
||||||
import type { PanelBaseProps } from '../../panel-list/PanelList';
|
import type { PanelBaseProps } from '../../panel-list/PanelList';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
import CustomViews from '../manage-panel/CustomViews';
|
||||||
import GeneralSettings from './GeneralSettings';
|
import GeneralSettings from './GeneralSettings';
|
||||||
import ProjectData from './ProjectData';
|
import ProjectData from './ProjectData';
|
||||||
import ServerPortSettings from './ServerPortSettings';
|
import ServerPortSettings from './ServerPortSettings';
|
||||||
@@ -10,8 +11,9 @@ import ViewSettings from './ViewSettings';
|
|||||||
export default function SettingsPanel({ location }: PanelBaseProps) {
|
export default function SettingsPanel({ location }: PanelBaseProps) {
|
||||||
const dataRef = useScrollIntoView<HTMLDivElement>('data', location);
|
const dataRef = useScrollIntoView<HTMLDivElement>('data', location);
|
||||||
const generalRef = useScrollIntoView<HTMLDivElement>('general', location);
|
const generalRef = useScrollIntoView<HTMLDivElement>('general', location);
|
||||||
const portRef = useScrollIntoView<HTMLDivElement>('port', location);
|
|
||||||
const viewRef = useScrollIntoView<HTMLDivElement>('view', location);
|
const viewRef = useScrollIntoView<HTMLDivElement>('view', location);
|
||||||
|
const customViewsRef = useScrollIntoView<HTMLDivElement>('custom-views', location);
|
||||||
|
const portRef = useScrollIntoView<HTMLDivElement>('port', location);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -25,6 +27,9 @@ export default function SettingsPanel({ location }: PanelBaseProps) {
|
|||||||
<div ref={viewRef}>
|
<div ref={viewRef}>
|
||||||
<ViewSettings />
|
<ViewSettings />
|
||||||
</div>
|
</div>
|
||||||
|
<div ref={customViewsRef}>
|
||||||
|
<CustomViews />
|
||||||
|
</div>
|
||||||
{!isDocker && (
|
{!isDocker && (
|
||||||
<div ref={portRef}>
|
<div ref={portRef}>
|
||||||
<ServerPortSettings />
|
<ServerPortSettings />
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ const staticOptions = [
|
|||||||
{ id: 'settings__data', label: 'Project data' },
|
{ id: 'settings__data', label: 'Project data' },
|
||||||
{ id: 'settings__general', label: 'General settings' },
|
{ id: 'settings__general', label: 'General settings' },
|
||||||
{ id: 'settings__view', label: 'View settings' },
|
{ id: 'settings__view', label: 'View settings' },
|
||||||
{ id: 'settings__port', label: 'Server Port' },
|
{ id: 'settings__custom-views', label: 'Custom views' },
|
||||||
|
{ id: 'settings__port', label: 'Server port' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -48,7 +48,7 @@
|
|||||||
"dev:inspect": "cross-env NODE_ENV=development tsx watch --tsconfig tsconfig.app.json --inspect ./src/index.ts",
|
"dev:inspect": "cross-env NODE_ENV=development tsx watch --tsconfig tsconfig.app.json --inspect ./src/index.ts",
|
||||||
"lint": "oxlint --quiet --type-aware",
|
"lint": "oxlint --quiet --type-aware",
|
||||||
"typecheck": "tsc -p tsconfig.app.json --noEmit",
|
"typecheck": "tsc -p tsconfig.app.json --noEmit",
|
||||||
"prebuild": "tsx --tsconfig tsconfig.app.json ./scripts/bundleCss.ts && tsx --tsconfig tsconfig.app.json ./scripts/bundleTranslation.ts",
|
"prebuild": "tsx --tsconfig tsconfig.app.json ./scripts/bundleDefaults.ts",
|
||||||
"build": "node esbuild.js",
|
"build": "node esbuild.js",
|
||||||
"test": "cross-env IS_TEST=true vitest",
|
"test": "cross-env IS_TEST=true vitest",
|
||||||
"test:inspect": "cross-env IS_TEST=true vitest --inspect --no-file-parallelism",
|
"test:inspect": "cross-env IS_TEST=true vitest --inspect --no-file-parallelism",
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
import { writeFile } from 'node:fs/promises';
|
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
import { defaultCss } from '../src/user/styles/bundledCss';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Script to write contents of bundledCss to override.css
|
|
||||||
*/
|
|
||||||
async function bundleCss() {
|
|
||||||
try {
|
|
||||||
const stylesDir = path.resolve(process.cwd(), 'src', 'user', 'styles');
|
|
||||||
const cssFile = path.resolve(stylesDir, 'override.css');
|
|
||||||
|
|
||||||
await writeFile(cssFile, defaultCss, { encoding: 'utf8' });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed writing to CSS file: ', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bundleCss();
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import { defaultCss } from '../src/bundle/bundledCss';
|
||||||
|
import { defaultDemoHtml } from '../src/bundle/bundledDemoHtml';
|
||||||
|
import { defaultTranslation } from '../src/bundle/bundledTranslations';
|
||||||
|
import { ensureDirectory, writeToFile } from '../src/utils/fileManagement';
|
||||||
|
|
||||||
|
const srcDir = path.resolve(process.cwd(), 'src');
|
||||||
|
|
||||||
|
const bundles = [
|
||||||
|
{ file: path.resolve(srcDir, 'user', 'styles', 'override.css'), content: defaultCss, label: 'CSS' },
|
||||||
|
{
|
||||||
|
file: path.resolve(srcDir, 'user', 'translations', 'translations.json'),
|
||||||
|
content: defaultTranslation,
|
||||||
|
label: 'Translation',
|
||||||
|
},
|
||||||
|
{ file: path.resolve(srcDir, 'external', 'demo', 'index.html'), content: defaultDemoHtml, label: 'Demo HTML' },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function bundleDefaults() {
|
||||||
|
for (const { file, content, label } of bundles) {
|
||||||
|
try {
|
||||||
|
ensureDirectory(path.dirname(file));
|
||||||
|
await writeToFile(file, content, { encoding: 'utf8' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed writing ${label} file: `, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bundleDefaults();
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { writeFile } from 'node:fs/promises';
|
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
import { defaultTranslation } from '../src/user/translations/bundledTranslations.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Script to write contents of default translation to translation.json
|
|
||||||
*/
|
|
||||||
async function bundleTranslation() {
|
|
||||||
try {
|
|
||||||
const translationDir = path.resolve(process.cwd(), 'src', 'user', 'translations');
|
|
||||||
const translationsFile = path.resolve(translationDir, 'translations.json');
|
|
||||||
|
|
||||||
await writeFile(translationsFile, defaultTranslation, { encoding: 'utf8' });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed writing to translations file: ', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bundleTranslation();
|
|
||||||
@@ -4,7 +4,7 @@ import { type ErrorResponse, RefetchKey } from 'ontime-types';
|
|||||||
import { getErrorMessage } from 'ontime-utils';
|
import { getErrorMessage } from 'ontime-utils';
|
||||||
|
|
||||||
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
|
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
|
||||||
import { defaultCss } from '../../user/styles/bundledCss.js';
|
import { defaultCss } from '../../bundle/bundledCss.js';
|
||||||
import { readCssFile, writeCssFile, writeUserTranslation } from './assets.service.js';
|
import { readCssFile, writeCssFile, writeUserTranslation } from './assets.service.js';
|
||||||
import { validatePostCss, validatePostTranslation } from './assets.validation.js';
|
import { validatePostCss, validatePostTranslation } from './assets.validation.js';
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { readFile, writeFile } from 'node:fs/promises';
|
|||||||
|
|
||||||
import type { TranslationObject } from 'ontime-types';
|
import type { TranslationObject } from 'ontime-types';
|
||||||
|
|
||||||
|
import { defaultCss } from '../../bundle/bundledCss.js';
|
||||||
import { publicFiles } from '../../setup/index.js';
|
import { publicFiles } from '../../setup/index.js';
|
||||||
import { defaultCss } from '../../user/styles/bundledCss.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads the user's css file
|
* Reads the user's css file
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { CustomViewError } from '../customViews.errors.js';
|
||||||
|
import { isValidCustomViewSlug, resolveCustomViewDirectory, validateHtmlContent } from '../customViews.service.js';
|
||||||
|
|
||||||
|
describe('isValidCustomViewSlug()', () => {
|
||||||
|
it('accepts valid slugs', () => {
|
||||||
|
expect(isValidCustomViewSlug('a')).toBe(true);
|
||||||
|
expect(isValidCustomViewSlug('my-view-1')).toBe(true);
|
||||||
|
expect(isValidCustomViewSlug('example123')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty or too long', () => {
|
||||||
|
expect(isValidCustomViewSlug('')).toBe(false);
|
||||||
|
expect(isValidCustomViewSlug('a'.repeat(64))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid characters', () => {
|
||||||
|
expect(isValidCustomViewSlug('Hello')).toBe(false);
|
||||||
|
expect(isValidCustomViewSlug('my_view')).toBe(false);
|
||||||
|
expect(isValidCustomViewSlug('my view')).toBe(false);
|
||||||
|
expect(isValidCustomViewSlug('../escape')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects leading or trailing hyphens', () => {
|
||||||
|
expect(isValidCustomViewSlug('-start')).toBe(false);
|
||||||
|
expect(isValidCustomViewSlug('end-')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveCustomViewDirectory()', () => {
|
||||||
|
it('throws CustomViewError on invalid slugs', () => {
|
||||||
|
expect(() => resolveCustomViewDirectory('Hello-World')).toThrow(CustomViewError);
|
||||||
|
expect(() => resolveCustomViewDirectory('../escape')).toThrow(CustomViewError);
|
||||||
|
expect(() => resolveCustomViewDirectory('')).toThrow(CustomViewError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateHtmlContent()', () => {
|
||||||
|
it('accepts valid HTML with doctype', () => {
|
||||||
|
expect(() => validateHtmlContent('<!DOCTYPE html><html><body>hello</body></html>')).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts valid HTML starting with html tag', () => {
|
||||||
|
expect(() => validateHtmlContent('<html><body>hello</body></html>')).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts HTML with inline script and style', () => {
|
||||||
|
const html = '<!DOCTYPE html><html><head><style>body{}</style></head><body><script>alert(1)</script></body></html>';
|
||||||
|
expect(() => validateHtmlContent(html)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects content that is not HTML', () => {
|
||||||
|
expect(() => validateHtmlContent('just some text')).toThrow(CustomViewError);
|
||||||
|
expect(() => validateHtmlContent('{"json": true}')).toThrow(CustomViewError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects external script imports', () => {
|
||||||
|
const html = '<!DOCTYPE html><html><body><script src="https://cdn.example.com/app.js"></script></body></html>';
|
||||||
|
expect(() => validateHtmlContent(html)).toThrow('External scripts are not allowed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects external stylesheets', () => {
|
||||||
|
const html = '<!DOCTYPE html><html><head><link rel="stylesheet" href="styles.css"></head><body></body></html>';
|
||||||
|
expect(() => validateHtmlContent(html)).toThrow('External stylesheets are not allowed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects iframes', () => {
|
||||||
|
const html = '<!DOCTYPE html><html><body><iframe src="https://example.com"></iframe></body></html>';
|
||||||
|
expect(() => validateHtmlContent(html)).toThrow('Iframes are not allowed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows link tags that are not stylesheets', () => {
|
||||||
|
const html = '<!DOCTYPE html><html><head><link rel="icon" href="data:,"></head><body></body></html>';
|
||||||
|
expect(() => validateHtmlContent(html)).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { Response } from 'express';
|
||||||
|
import type { ErrorResponse } from 'ontime-types';
|
||||||
|
import { getErrorMessage } from 'ontime-utils';
|
||||||
|
|
||||||
|
export class CustomViewError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public readonly statusCode: number,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'CustomViewError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleCustomViewsError(error: unknown, res: Response<ErrorResponse>) {
|
||||||
|
if (error instanceof CustomViewError) {
|
||||||
|
res.status(error.statusCode).send({ message: error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(500).send({ message: getErrorMessage(error) });
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { NextFunction, Request, Response } from 'express';
|
||||||
|
import multer from 'multer';
|
||||||
|
import type { ErrorResponse } from 'ontime-types';
|
||||||
|
|
||||||
|
import { customViewMaxFileSize } from './customViews.service.js';
|
||||||
|
|
||||||
|
const allowedMimeTypes = new Set(['text/html', 'application/xhtml+xml', 'application/octet-stream']);
|
||||||
|
|
||||||
|
const upload = multer({
|
||||||
|
storage: multer.memoryStorage(),
|
||||||
|
limits: {
|
||||||
|
fileSize: customViewMaxFileSize,
|
||||||
|
files: 1,
|
||||||
|
},
|
||||||
|
fileFilter: (_req, file, cb) => {
|
||||||
|
if (allowedMimeTypes.has(file.mimetype)) {
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(new Error(`Unsupported file type "${file.mimetype}"`));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}).single('indexHtml');
|
||||||
|
|
||||||
|
export function uploadCustomViewFile(req: Request, res: Response<ErrorResponse>, next: NextFunction) {
|
||||||
|
upload(req, res, (error: unknown) => {
|
||||||
|
if (!error) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof multer.MulterError && error.code === 'LIMIT_FILE_SIZE') {
|
||||||
|
res.status(413).send({ message: `File size limit (${customViewMaxFileSize / 1_000_000}MB) exceeded` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof multer.MulterError && error.code === 'LIMIT_UNEXPECTED_FILE') {
|
||||||
|
res.status(400).send({ message: 'Unexpected upload field. Use "indexHtml"' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
res.status(400).send({ message: error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(400).send({ message: 'Could not process upload request' });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { type CustomViewsListResponse, type ErrorResponse, type MessageResponse } from 'ontime-types';
|
||||||
|
|
||||||
|
import { handleCustomViewsError } from './customViews.errors.js';
|
||||||
|
import { uploadCustomViewFile } from './customViews.middleware.js';
|
||||||
|
import {
|
||||||
|
deleteCustomView,
|
||||||
|
getCustomViewDownloadPath,
|
||||||
|
listCustomViews,
|
||||||
|
restoreDemoView,
|
||||||
|
uploadCustomView,
|
||||||
|
} from './customViews.service.js';
|
||||||
|
import { validateCustomViewSlugParam } from './customViews.validation.js';
|
||||||
|
|
||||||
|
export const router = express.Router();
|
||||||
|
|
||||||
|
router.get('/', async (_req: Request, res: Response<CustomViewsListResponse | ErrorResponse>) => {
|
||||||
|
try {
|
||||||
|
const views = await listCustomViews();
|
||||||
|
res.status(200).send({ views });
|
||||||
|
} catch (error) {
|
||||||
|
handleCustomViewsError(error, res);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/restore-demo', async (_req: Request, res: Response<MessageResponse | ErrorResponse>) => {
|
||||||
|
try {
|
||||||
|
const view = await restoreDemoView();
|
||||||
|
res.status(201).send({ message: `Restored demo view "${view.slug}"` });
|
||||||
|
} catch (error) {
|
||||||
|
handleCustomViewsError(error, res);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/:slug/upload',
|
||||||
|
validateCustomViewSlugParam,
|
||||||
|
uploadCustomViewFile,
|
||||||
|
async (req: Request, res: Response<MessageResponse | ErrorResponse>) => {
|
||||||
|
try {
|
||||||
|
const view = await uploadCustomView(req.params.slug, req.file);
|
||||||
|
res.status(201).send({ message: `Uploaded custom view "${view.slug}"` });
|
||||||
|
} catch (error) {
|
||||||
|
handleCustomViewsError(error, res);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get('/:slug/download', validateCustomViewSlugParam, async (req: Request, res: Response<ErrorResponse>) => {
|
||||||
|
try {
|
||||||
|
const pathToFile = await getCustomViewDownloadPath(req.params.slug);
|
||||||
|
const fileName = `${req.params.slug}-index.html`;
|
||||||
|
|
||||||
|
res.download(pathToFile, fileName, (error: Error | null) => {
|
||||||
|
if (error && !res.headersSent) {
|
||||||
|
res.status(500).send({ message: 'Could not download custom view' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
handleCustomViewsError(error, res);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:slug', validateCustomViewSlugParam, async (req: Request, res: Response<ErrorResponse>) => {
|
||||||
|
try {
|
||||||
|
await deleteCustomView(req.params.slug);
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (error) {
|
||||||
|
handleCustomViewsError(error, res);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { join, resolve } from 'node:path';
|
||||||
|
|
||||||
|
import { type CustomViewSummary } from 'ontime-types';
|
||||||
|
|
||||||
|
import { defaultDemoHtml } from '../../bundle/bundledDemoHtml.js';
|
||||||
|
import { publicDir } from '../../setup/index.js';
|
||||||
|
import {
|
||||||
|
createDirectory,
|
||||||
|
deleteDirectory,
|
||||||
|
ensureDirectory,
|
||||||
|
fileIsReadable,
|
||||||
|
isNodeError,
|
||||||
|
readDirectoryEntries,
|
||||||
|
replaceDirectory,
|
||||||
|
statIfExists,
|
||||||
|
writeToFile,
|
||||||
|
} from '../../utils/fileManagement.js';
|
||||||
|
import { CustomViewError } from './customViews.errors.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Patterns that indicate external resource loading.
|
||||||
|
* Custom views must be self-contained single HTML files with only inline CSS and JavaScript.
|
||||||
|
*/
|
||||||
|
const forbiddenHtmlPatterns: { pattern: RegExp; message: string }[] = [
|
||||||
|
{ pattern: /<script[^>]+src\s*=/i, message: 'External scripts are not allowed. Use inline <script> instead.' },
|
||||||
|
{
|
||||||
|
pattern: /<link[^>]+rel\s*=\s*["']?stylesheet["']?/i,
|
||||||
|
message: 'External stylesheets are not allowed. Use inline <style> instead.',
|
||||||
|
},
|
||||||
|
{ pattern: /<iframe[\s>]/i, message: 'Iframes are not allowed.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function validateHtmlContent(content: string): void {
|
||||||
|
const htmlDoctype = /^\s*<!doctype\s+html[\s>]/i;
|
||||||
|
const htmlTag = /^\s*<html[\s>]/i;
|
||||||
|
if (!htmlDoctype.test(content) && !htmlTag.test(content)) {
|
||||||
|
throw new CustomViewError(
|
||||||
|
'File does not appear to be valid HTML. Expected <!DOCTYPE html> or <html> at the start.',
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { pattern, message } of forbiddenHtmlPatterns) {
|
||||||
|
if (pattern.test(content)) {
|
||||||
|
throw new CustomViewError(message, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedSlugChars = /^[a-z0-9-]+$/;
|
||||||
|
export function isValidCustomViewSlug(slug: string): boolean {
|
||||||
|
if (typeof slug !== 'string') return false;
|
||||||
|
if (slug.length < 1 || slug.length > 63) return false;
|
||||||
|
if (!allowedSlugChars.test(slug)) return false;
|
||||||
|
if (slug.startsWith('-') || slug.endsWith('-')) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveCustomViewDirectory(slug: string): string {
|
||||||
|
if (!isValidCustomViewSlug(slug)) {
|
||||||
|
throw new CustomViewError('Invalid name. Use lowercase letters, numbers, and dashes only.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolve(publicDir.externalDir, slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPathToCustomView(slug: string): string {
|
||||||
|
return join(resolveCustomViewDirectory(slug), customViewIndexFilename);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomViewUploadFile {
|
||||||
|
originalname: string;
|
||||||
|
mimetype: string;
|
||||||
|
size: number;
|
||||||
|
buffer: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const customViewMaxFileSize = 4_000_000; // 4MB
|
||||||
|
export const customViewIndexFilename = 'index.html';
|
||||||
|
export function validateCustomViewUpload(file: CustomViewUploadFile | undefined): CustomViewUploadFile {
|
||||||
|
if (!file) {
|
||||||
|
throw new CustomViewError('File not found', 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = file.originalname.trim().toLowerCase();
|
||||||
|
if (fileName !== customViewIndexFilename) {
|
||||||
|
throw new CustomViewError('Only index.html uploads are supported', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size === 0) {
|
||||||
|
throw new CustomViewError('Uploaded file is empty', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > customViewMaxFileSize) {
|
||||||
|
throw new CustomViewError(`File size limit (${customViewMaxFileSize / 1_000_000}MB) exceeded`, 413);
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = file.buffer.toString('utf-8');
|
||||||
|
validateHtmlContent(content);
|
||||||
|
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listCustomViews(): Promise<CustomViewSummary[]> {
|
||||||
|
ensureDirectory(publicDir.externalDir);
|
||||||
|
|
||||||
|
const entries = await readDirectoryEntries(publicDir.externalDir);
|
||||||
|
const views: CustomViewSummary[] = [];
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory() || entry.name.startsWith('.') || !isValidCustomViewSlug(entry.name)) continue;
|
||||||
|
|
||||||
|
const indexStats = await statIfExists(getPathToCustomView(entry.name));
|
||||||
|
if (!indexStats?.isFile()) continue;
|
||||||
|
|
||||||
|
views.push({ slug: entry.name });
|
||||||
|
}
|
||||||
|
|
||||||
|
return views.sort((a, b) => a.slug.localeCompare(b.slug));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadCustomView(
|
||||||
|
slug: string,
|
||||||
|
file: CustomViewUploadFile | undefined,
|
||||||
|
): Promise<CustomViewSummary> {
|
||||||
|
const uploadFile = validateCustomViewUpload(file);
|
||||||
|
ensureDirectory(publicDir.externalDir);
|
||||||
|
|
||||||
|
const viewDirectory = resolveCustomViewDirectory(slug);
|
||||||
|
const indexFile = getPathToCustomView(slug);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createDirectory(viewDirectory);
|
||||||
|
} catch (error) {
|
||||||
|
if (isNodeError(error) && error.code === 'EEXIST') {
|
||||||
|
throw new CustomViewError(`Name "${slug}" already exists`, 409);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await writeToFile(indexFile, uploadFile.buffer);
|
||||||
|
return { slug };
|
||||||
|
} catch (error) {
|
||||||
|
await deleteDirectory(viewDirectory);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCustomViewDownloadPath(slug: string): Promise<string> {
|
||||||
|
const indexFile = getPathToCustomView(slug);
|
||||||
|
|
||||||
|
if (!(await fileIsReadable(indexFile))) {
|
||||||
|
throw new CustomViewError(`Custom view "${slug}" not found`, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return indexFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
const demoViewSlug = 'demo';
|
||||||
|
export async function restoreDemoView(): Promise<CustomViewSummary> {
|
||||||
|
ensureDirectory(publicDir.externalDir);
|
||||||
|
|
||||||
|
const viewDirectory = resolveCustomViewDirectory(demoViewSlug);
|
||||||
|
const indexFile = getPathToCustomView(demoViewSlug);
|
||||||
|
|
||||||
|
await replaceDirectory(viewDirectory);
|
||||||
|
await writeToFile(indexFile, defaultDemoHtml, { encoding: 'utf-8' });
|
||||||
|
|
||||||
|
return { slug: demoViewSlug };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCustomView(slug: string): Promise<void> {
|
||||||
|
await deleteDirectory(resolveCustomViewDirectory(slug));
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { param } from 'express-validator';
|
||||||
|
|
||||||
|
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
|
||||||
|
import { isValidCustomViewSlug } from './customViews.service.js';
|
||||||
|
|
||||||
|
export const validateCustomViewSlugParam = [
|
||||||
|
param('slug')
|
||||||
|
.isString()
|
||||||
|
.trim()
|
||||||
|
.notEmpty()
|
||||||
|
.customSanitizer((value: string) => value.toLowerCase())
|
||||||
|
.custom((value: string) => isValidCustomViewSlug(value))
|
||||||
|
.withMessage('Invalid name. Use lowercase letters, numbers, and dashes only.'),
|
||||||
|
|
||||||
|
requestValidationFunction,
|
||||||
|
];
|
||||||
@@ -3,6 +3,7 @@ import express from 'express';
|
|||||||
import { router as assetsRouter } from './assets/assets.router.js';
|
import { router as assetsRouter } from './assets/assets.router.js';
|
||||||
import { router as automationsRouter } from './automation/automation.router.js';
|
import { router as automationsRouter } from './automation/automation.router.js';
|
||||||
import { router as customFieldsRouter } from './custom-fields/customFields.router.js';
|
import { router as customFieldsRouter } from './custom-fields/customFields.router.js';
|
||||||
|
import { router as customViewsRouter } from './custom-views/customViews.router.js';
|
||||||
import { router as dbRouter } from './db/db.router.js';
|
import { router as dbRouter } from './db/db.router.js';
|
||||||
import { router as excelRouter } from './excel/excel.router.js';
|
import { router as excelRouter } from './excel/excel.router.js';
|
||||||
import { router as projectRouter } from './project-data/projectData.router.js';
|
import { router as projectRouter } from './project-data/projectData.router.js';
|
||||||
@@ -18,6 +19,7 @@ export const appRouter = express.Router();
|
|||||||
|
|
||||||
appRouter.use('/automations', automationsRouter);
|
appRouter.use('/automations', automationsRouter);
|
||||||
appRouter.use('/custom-fields', customFieldsRouter);
|
appRouter.use('/custom-fields', customFieldsRouter);
|
||||||
|
appRouter.use('/custom-views', customViewsRouter);
|
||||||
appRouter.use('/db', dbRouter);
|
appRouter.use('/db', dbRouter);
|
||||||
appRouter.use('/project', projectRouter);
|
appRouter.use('/project', projectRouter);
|
||||||
appRouter.use('/rundowns', rundownsRouter);
|
appRouter.use('/rundowns', rundownsRouter);
|
||||||
|
|||||||
@@ -0,0 +1,305 @@
|
|||||||
|
export const defaultDemoHtml = `<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<!-- For detailed explanations and examples, refer to the README.md file in this directory -->
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
|
||||||
|
<title>ontime demo</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
max-width: 100vw;
|
||||||
|
overflow-x: hidden;
|
||||||
|
font-family: 'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
|
background: #f6f6f6;
|
||||||
|
color: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container .column {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #eaeaea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-title img {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card summary.title {
|
||||||
|
border-bottom: 1px solid #ccc;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1.title,
|
||||||
|
summary.title {
|
||||||
|
font-size: 0.95em;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
summary.title {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-size: 0.75em;
|
||||||
|
font-family: monospace;
|
||||||
|
background: #f4f4f4;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1.5px 3px;
|
||||||
|
display: inline-block;
|
||||||
|
white-space: pre;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
figure {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
figcaption.description {
|
||||||
|
color: #555;
|
||||||
|
font-size: 0.75em;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<header class="title-card">
|
||||||
|
<div class="logo-title">
|
||||||
|
<img
|
||||||
|
src="https://www.getontime.no/images/icons/ontime-logo.png"
|
||||||
|
alt="Ontime logo"
|
||||||
|
onerror="this.style.display = 'none'"
|
||||||
|
/>
|
||||||
|
<h1 class="title">Ontime demo</h1>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Last message received at</span>
|
||||||
|
<span id="clock">-</span>
|
||||||
|
</div>
|
||||||
|
<nav>
|
||||||
|
<a href="https://docs.getontime.no/api/data/runtime-data" target="_blank">Help? See docs</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="container">
|
||||||
|
<section class="column">
|
||||||
|
<details class="card" open>
|
||||||
|
<summary class="title">Timer</summary>
|
||||||
|
<figure>
|
||||||
|
<figcaption class="description">Current timer values</figcaption>
|
||||||
|
<code id="timer">-</code>
|
||||||
|
</figure>
|
||||||
|
</details>
|
||||||
|
<details class="card" open>
|
||||||
|
<summary class="title">Rundown</summary>
|
||||||
|
<figure>
|
||||||
|
<figcaption class="description">Progress of the current rundown</figcaption>
|
||||||
|
<code id="rundown">-</code>
|
||||||
|
</figure>
|
||||||
|
</details>
|
||||||
|
<details class="card" open>
|
||||||
|
<summary class="title">Offset</summary>
|
||||||
|
<figure>
|
||||||
|
<figcaption class="description">Runtime offset and timings for upcoming targets</figcaption>
|
||||||
|
<code id="offset">-</code>
|
||||||
|
</figure>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
<section class="column">
|
||||||
|
<details class="card" open>
|
||||||
|
<summary class="title">Event now</summary>
|
||||||
|
<figure>
|
||||||
|
<figcaption class="description">Currently loaded event</figcaption>
|
||||||
|
<code id="eventNow">-</code>
|
||||||
|
</figure>
|
||||||
|
</details>
|
||||||
|
<details class="card" open>
|
||||||
|
<summary class="title">Event next</summary>
|
||||||
|
<figure>
|
||||||
|
<figcaption class="description">Next scheduled event</figcaption>
|
||||||
|
<code id="eventNext">-</code>
|
||||||
|
</figure>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
<section class="column">
|
||||||
|
<details class="card" open>
|
||||||
|
<summary class="title">Group now</summary>
|
||||||
|
<figure>
|
||||||
|
<figcaption class="description">Currently active group</figcaption>
|
||||||
|
<code id="groupNow">-</code>
|
||||||
|
</figure>
|
||||||
|
</details>
|
||||||
|
<details class="card" open>
|
||||||
|
<summary class="title">Event flag</summary>
|
||||||
|
<figure>
|
||||||
|
<figcaption class="description">Currently targeted flag</figcaption>
|
||||||
|
<code id="eventFlag">-</code>
|
||||||
|
</figure>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
<section class="column">
|
||||||
|
<details class="card" open>
|
||||||
|
<summary class="title">Message</summary>
|
||||||
|
<figure>
|
||||||
|
<figcaption class="description">Messaging feature</figcaption>
|
||||||
|
<code id="message">-</code>
|
||||||
|
</figure>
|
||||||
|
</details>
|
||||||
|
<details class="card" open>
|
||||||
|
<summary class="title">Aux timers</summary>
|
||||||
|
<figure>
|
||||||
|
<figcaption class="description">Auxiliary Timer 1</figcaption>
|
||||||
|
<code id="auxtimer1">-</code>
|
||||||
|
</figure>
|
||||||
|
<figure>
|
||||||
|
<figcaption class="description">Auxiliary Timer 2</figcaption>
|
||||||
|
<code id="auxtimer2">-</code>
|
||||||
|
</figure>
|
||||||
|
<figure>
|
||||||
|
<figcaption class="description">Auxiliary Timer 3</figcaption>
|
||||||
|
<code id="auxtimer3">-</code>
|
||||||
|
</figure>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const isSecure = window.location.protocol === 'https:';
|
||||||
|
const userProvidedSocketUrl = \`\${isSecure ? 'wss' : 'ws'}://\${window.location.host}\${getStageHash()}/ws\`;
|
||||||
|
|
||||||
|
connectSocket();
|
||||||
|
|
||||||
|
let reconnectTimeout;
|
||||||
|
const reconnectInterval = 1000;
|
||||||
|
let reconnectAttempts = 0;
|
||||||
|
|
||||||
|
function connectSocket(socketUrl = userProvidedSocketUrl) {
|
||||||
|
const websocket = new WebSocket(socketUrl);
|
||||||
|
|
||||||
|
websocket.onopen = () => {
|
||||||
|
clearTimeout(reconnectTimeout);
|
||||||
|
reconnectAttempts = 0;
|
||||||
|
console.warn('WebSocket connected');
|
||||||
|
};
|
||||||
|
|
||||||
|
websocket.onclose = () => {
|
||||||
|
console.warn('WebSocket disconnected');
|
||||||
|
reconnectTimeout = setTimeout(() => {
|
||||||
|
console.warn(\`WebSocket: attempting reconnect \${reconnectAttempts}\`);
|
||||||
|
if (websocket && websocket.readyState === WebSocket.CLOSED) {
|
||||||
|
reconnectAttempts += 1;
|
||||||
|
connectSocket();
|
||||||
|
}
|
||||||
|
}, reconnectInterval);
|
||||||
|
};
|
||||||
|
websocket.onerror = (error) => {
|
||||||
|
console.error('WebSocket error:', error);
|
||||||
|
};
|
||||||
|
|
||||||
|
websocket.onmessage = (event) => {
|
||||||
|
const { tag, payload } = JSON.parse(event.data);
|
||||||
|
if (tag === 'runtime-data') {
|
||||||
|
handleOntimePayload(payload);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let localData = {};
|
||||||
|
function handleOntimePayload(payload) {
|
||||||
|
localData = { ...localData, ...payload };
|
||||||
|
|
||||||
|
if ('clock' in payload) updateDOM('clock', formatTimer(payload.clock));
|
||||||
|
if ('timer' in payload) updateDOM('timer', formatObject(payload.timer));
|
||||||
|
if ('rundown' in payload) updateDOM('rundown', formatObject(payload.rundown));
|
||||||
|
if ('offset' in payload) updateDOM('offset', formatObject(payload.offset));
|
||||||
|
if ('eventNow' in payload) updateDOM('eventNow', formatObject(payload.eventNow));
|
||||||
|
if ('eventNext' in payload) updateDOM('eventNext', formatObject(payload.eventNext));
|
||||||
|
if ('eventFlag' in payload) updateDOM('eventFlag', formatObject(payload.eventFlag));
|
||||||
|
if ('groupNow' in payload) updateDOM('groupNow', formatObject(payload.groupNow));
|
||||||
|
if ('message' in payload) updateDOM('message', formatObject(payload.message));
|
||||||
|
if ('auxtimer1' in payload) updateDOM('auxtimer1', formatObject(payload.auxtimer1));
|
||||||
|
if ('auxtimer2' in payload) updateDOM('auxtimer2', formatObject(payload.auxtimer2));
|
||||||
|
if ('auxtimer3' in payload) updateDOM('auxtimer3', formatObject(payload.auxtimer3));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDOM(field, payload) {
|
||||||
|
const domElement = document.getElementById(field);
|
||||||
|
if (domElement) {
|
||||||
|
domElement.innerText = payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const millisToSeconds = 1000;
|
||||||
|
const millisToMinutes = 1000 * 60;
|
||||||
|
const millisToHours = 1000 * 60 * 60;
|
||||||
|
|
||||||
|
function formatTimer(number) {
|
||||||
|
if (number == null) {
|
||||||
|
return '--:--:--';
|
||||||
|
}
|
||||||
|
const millis = Math.abs(number);
|
||||||
|
const isNegative = number < 0;
|
||||||
|
return \`\${isNegative ? '-' : ''}\${leftPad(millis / millisToHours)}:\${leftPad(
|
||||||
|
(millis % millisToHours) / millisToMinutes,
|
||||||
|
)}:\${leftPad((millis % millisToMinutes) / millisToSeconds)}\`;
|
||||||
|
|
||||||
|
function leftPad(val) {
|
||||||
|
return Math.floor(val).toString().padStart(2, '0');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatObject(data) {
|
||||||
|
return JSON.stringify(data, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStageHash() {
|
||||||
|
const href = window.location.href;
|
||||||
|
if (!href.includes('getontime.no')) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const hash = href.split('/');
|
||||||
|
const stageHash = hash.at(3);
|
||||||
|
return stageHash ? \`/\${stageHash}\` : '';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
Vendored
+406
@@ -8,3 +8,409 @@ http://<ip-address>:<port>/external/<folder-name>
|
|||||||
```
|
```
|
||||||
|
|
||||||
https://docs.getontime.no/features/custom-views/
|
https://docs.getontime.no/features/custom-views/
|
||||||
|
|
||||||
|
## Demo
|
||||||
|
|
||||||
|
This is a demo application which demonstrates how to create a custom view leveraging a websocket client to get data from Ontime.
|
||||||
|
|
||||||
|
Here, we subscribe to the websocket and display all the data received in a grid.
|
||||||
|
|
||||||
|
Please note this demo tries to be simple and clear. You would likely want to implement a more robust solution in a production environment.
|
||||||
|
|
||||||
|
### Getting the data
|
||||||
|
|
||||||
|
To subscribe to the websocket you will need:
|
||||||
|
|
||||||
|
- The address of the Ontime server (including the IP): eg, `cloud.getontime.no/stage-hash` or `192.168.1.1:4001`
|
||||||
|
- If the stage is password protected, you will also need to provide a token to access the data. You can get this token by generating a share link for Companion (Editor > Settings > Share link) and ensuring the "Authenticate Link" option is on.
|
||||||
|
|
||||||
|
#### Example
|
||||||
|
|
||||||
|
- Ontime URL: `https://cloud.getontime.no/stage-123`
|
||||||
|
- Ontime token: `token-from-share`
|
||||||
|
|
||||||
|
```js
|
||||||
|
// use wss since we are connecting to an https address
|
||||||
|
const socketUrl = `wss://cloud.getontime.no/stage-123/ws?token=token-from-share`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connects to the websocket server
|
||||||
|
* NOTE: this demo does not handle reconnections or errors
|
||||||
|
* @param {string} socketUrl
|
||||||
|
*/
|
||||||
|
const connectSocket = (socketUrl) => {
|
||||||
|
const websocket = new WebSocket(socketUrl);
|
||||||
|
|
||||||
|
websocket.onmessage = (event) => {
|
||||||
|
// all objects from ontime are structured with tag and payload
|
||||||
|
const { tag, payload } = JSON.parse(event.data);
|
||||||
|
|
||||||
|
// runtime-data is sent on connect, with the full state
|
||||||
|
// runtime-patch is sent on every change to the state
|
||||||
|
if (tag === 'runtime-data') {
|
||||||
|
handleOntimePayload(payload);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Runtime data
|
||||||
|
|
||||||
|
`runtime-data` contains a patch of all the data in the server
|
||||||
|
you would need to create a function that parses the patch and extract the data you need
|
||||||
|
|
||||||
|
In our case, we simply map the data to a DOM element with the same ID as the field name.
|
||||||
|
|
||||||
|
[See the docs](https://docs.getontime.no/api/data/runtime-data/).
|
||||||
|
|
||||||
|
#### Example of handling the payload
|
||||||
|
|
||||||
|
```js
|
||||||
|
const handleOntimePayload = (payload) => {
|
||||||
|
// 1. apply the patch into your local copy of the data
|
||||||
|
localData = { ...localData, ...payload };
|
||||||
|
|
||||||
|
// 2. update the UI with the new data
|
||||||
|
// ... timer data
|
||||||
|
if ('clock' in payload) updateDOM('clock', formatTimer(payload.clock));
|
||||||
|
if ('timer' in payload) updateDOM('timer', formatObject(payload.timer));
|
||||||
|
// ... rundown data
|
||||||
|
if ('rundown' in payload) updateDOM('rundown', formatObject(payload.rundown));
|
||||||
|
// ... runtime
|
||||||
|
if ('offset' in payload) updateDOM('offset', formatObject(payload.offset));
|
||||||
|
// ... relevant entries
|
||||||
|
if ('eventNow' in payload) updateDOM('eventNow', formatObject(payload.eventNow));
|
||||||
|
if ('eventNext' in payload) updateDOM('eventNext', formatObject(payload.eventNext));
|
||||||
|
if ('eventFlag' in payload) updateDOM('eventFlag', formatObject(payload.eventFlag));
|
||||||
|
if ('groupNow' in payload) updateDOM('groupNow', formatObject(payload.groupNow));
|
||||||
|
// ... messages service
|
||||||
|
if ('message' in payload) updateDOM('message', formatObject(payload.message));
|
||||||
|
// ... extra timers
|
||||||
|
if ('auxtimer1' in payload) updateDOM('auxtimer1', formatObject(payload.auxtimer1));
|
||||||
|
if ('auxtimer2' in payload) updateDOM('auxtimer2', formatObject(payload.auxtimer2));
|
||||||
|
if ('auxtimer3' in payload) updateDOM('auxtimer3', formatObject(payload.auxtimer3));
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Payload example
|
||||||
|
|
||||||
|
See below what the payload looks like.
|
||||||
|
Note: all timer values are in milliseconds.
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
/** Current server clock value */
|
||||||
|
"clock": 37816011,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gathers the current running timer state
|
||||||
|
*/
|
||||||
|
"timer": {
|
||||||
|
/** Additional time added to the running timer, can be negative */
|
||||||
|
"addedTime": 0,
|
||||||
|
/** Current running timer countdown */
|
||||||
|
"current": 3574976,
|
||||||
|
/** Total duration of the running event */
|
||||||
|
"duration": 3600000,
|
||||||
|
/** Time elapsed since the timer started */
|
||||||
|
"elapsed": 25024,
|
||||||
|
/** Timestamp of the expected finish time */
|
||||||
|
"expectedFinish": 41391285,
|
||||||
|
/** Current phase of the running event */
|
||||||
|
"phase": "default",
|
||||||
|
/** Timer's playback state */
|
||||||
|
"playback": "play",
|
||||||
|
/** Secondary timer, used to count to an event start in roll mode */
|
||||||
|
"secondaryTimer": null,
|
||||||
|
/** Timestamp when the timer started */
|
||||||
|
"startedAt": 37791285,
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Offset represents our current position in relation to the planned time
|
||||||
|
* a positive value means that we have added extra time to the expected end
|
||||||
|
* aka behind schedule
|
||||||
|
*/
|
||||||
|
"offset": {
|
||||||
|
/** Current absolute offset: accounts for planned times */
|
||||||
|
"absolute": 40394840,
|
||||||
|
/** Current relative offset: only counts for generated offset since start */
|
||||||
|
"relative": -35997119,
|
||||||
|
/** Currently selected offset mode */
|
||||||
|
"mode": "absolute",
|
||||||
|
/** Timestamp of the expected start of the next flag */
|
||||||
|
"expectedFlagStart": 80594840,
|
||||||
|
/** Timestamp of the expected end of the current group */
|
||||||
|
"expectedGroupEnd": 83594840,
|
||||||
|
/** Timestamp of the expected end of the loaded rundown */
|
||||||
|
"expectedRundownEnd": 90794840,
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Data object describes rundown schedule and the current progress */
|
||||||
|
"rundown": {
|
||||||
|
/** Index of the currently selected event */
|
||||||
|
"selectedEventIndex": 1,
|
||||||
|
/** Total number of events */
|
||||||
|
"numEvents": 7,
|
||||||
|
/** Timestamp of the rundown's planned start time */
|
||||||
|
"plannedStart": 0,
|
||||||
|
/** Timestamp of the rundown's planned end time */
|
||||||
|
"plannedEnd": 50400000,
|
||||||
|
/** Timestamp of when the rundown was actually started */
|
||||||
|
"actualStart": 76391959,
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Data of currently loaded event */
|
||||||
|
"eventNow": {
|
||||||
|
/** Unique identifier for the event */
|
||||||
|
"id": "9bf60f",
|
||||||
|
/** Entry type */
|
||||||
|
"type": "event",
|
||||||
|
/** Whether the event is flagged */
|
||||||
|
"flag": false,
|
||||||
|
/** Title of the event */
|
||||||
|
"title": "Pre-show Countdown",
|
||||||
|
/** Timestamp of the planned start time */
|
||||||
|
"timeStart": 36000000,
|
||||||
|
/** Timestamp of the planned end time */
|
||||||
|
"timeEnd": 39600000,
|
||||||
|
/** Planned event duration */
|
||||||
|
"duration": 3600000,
|
||||||
|
/** Strategy for time management */
|
||||||
|
"timeStrategy": "lock-end",
|
||||||
|
/** Whether the event is linked to the start of the previous */
|
||||||
|
"linkStart": false,
|
||||||
|
/** Action to take at the end of the event */
|
||||||
|
"endAction": "none",
|
||||||
|
/** Type of timer used for the event */
|
||||||
|
"timerType": "count-down",
|
||||||
|
/** Whether the timer counts to the end */
|
||||||
|
"countToEnd": false,
|
||||||
|
/** Whether the event is skipped */
|
||||||
|
"skip": false,
|
||||||
|
/** Note associated with the event */
|
||||||
|
"note": "Music plays, holding slide on screens",
|
||||||
|
/** Colour code for the event */
|
||||||
|
"colour": "#77C785",
|
||||||
|
/** Current delay inherited from the rundown schedule */
|
||||||
|
"delay": 0,
|
||||||
|
/** Day offset for the event */
|
||||||
|
"dayOffset": 0,
|
||||||
|
/** Time gap between events */
|
||||||
|
"gap": 0,
|
||||||
|
/** Cue number for the event */
|
||||||
|
"cue": "1",
|
||||||
|
/** Parent group ID */
|
||||||
|
"parent": "7eaf99",
|
||||||
|
/** Revision number for the entry */
|
||||||
|
"revision": 0,
|
||||||
|
/** Warning time */
|
||||||
|
"timeWarning": 600000,
|
||||||
|
/** Danger time */
|
||||||
|
"timeDanger": 300000,
|
||||||
|
/** Custom fields for the event */
|
||||||
|
"custom": { "Custom_Field": "Put additional info here" },
|
||||||
|
/** Triggers associated with the event */
|
||||||
|
"triggers": [],
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Upcoming event data */
|
||||||
|
"eventNext": {
|
||||||
|
/** Unique identifier for the event */
|
||||||
|
"id": "c2697f",
|
||||||
|
/** Entry type */
|
||||||
|
"type": "event",
|
||||||
|
/** Whether the event is flagged */
|
||||||
|
"flag": false,
|
||||||
|
/** Title of the event */
|
||||||
|
"title": "Welcome",
|
||||||
|
/** Timestamp of the planned start time */
|
||||||
|
"timeStart": 39600000,
|
||||||
|
/** Timestamp of the planned end time */
|
||||||
|
"timeEnd": 40200000,
|
||||||
|
/** Planned event duration */
|
||||||
|
"duration": 600000,
|
||||||
|
/** Strategy for time management */
|
||||||
|
"timeStrategy": "lock-duration",
|
||||||
|
/** Whether the event is linked to the start of the previous */
|
||||||
|
"linkStart": true,
|
||||||
|
/** Action to take at the end of the event */
|
||||||
|
"endAction": "none",
|
||||||
|
/** Type of timer used for the event */
|
||||||
|
"timerType": "count-down",
|
||||||
|
/** Whether the timer counts to the end */
|
||||||
|
"countToEnd": false,
|
||||||
|
/** Whether the event is skipped */
|
||||||
|
"skip": false,
|
||||||
|
/** Note associated with the event */
|
||||||
|
"note": "Emma Thompson",
|
||||||
|
/** Colour code for the event */
|
||||||
|
"colour": "#FFCC78",
|
||||||
|
/** Current delay inherited from the rundown schedule */
|
||||||
|
"delay": 0,
|
||||||
|
/** Day offset for the event */
|
||||||
|
"dayOffset": 0,
|
||||||
|
/** Time gap between events */
|
||||||
|
"gap": 0,
|
||||||
|
/** Cue number for the event */
|
||||||
|
"cue": "1.1",
|
||||||
|
/** Parent group ID */
|
||||||
|
"parent": "7eaf99",
|
||||||
|
/** Revision number for the entry */
|
||||||
|
"revision": 0,
|
||||||
|
/** Warning time */
|
||||||
|
"timeWarning": 120000,
|
||||||
|
/** Danger time */
|
||||||
|
"timeDanger": 60000,
|
||||||
|
/** Custom fields for the event */
|
||||||
|
"custom": {},
|
||||||
|
/** Triggers associated with the event */
|
||||||
|
"triggers": [],
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Data of currently targetted flag event */
|
||||||
|
"eventFlag": {
|
||||||
|
/** Unique identifier for the event */
|
||||||
|
"id": "fa593e",
|
||||||
|
/** Entry type */
|
||||||
|
"type": "event",
|
||||||
|
/** Whether the event is flagged */
|
||||||
|
"flag": true,
|
||||||
|
/** Title of the event */
|
||||||
|
"title": "Session 1",
|
||||||
|
/** Timestamp of the planned start time */
|
||||||
|
"timeStart": 40200000,
|
||||||
|
/** Timestamp of the planned end time */
|
||||||
|
"timeEnd": 43200000,
|
||||||
|
/** Planned event duration */
|
||||||
|
"duration": 3000000,
|
||||||
|
/** Strategy for time management */
|
||||||
|
"timeStrategy": "lock-duration",
|
||||||
|
/** Whether the event is linked to the start of the previous */
|
||||||
|
"linkStart": true,
|
||||||
|
/** Action to take at the end of the event */
|
||||||
|
"endAction": "none",
|
||||||
|
/** Type of timer used for the event */
|
||||||
|
"timerType": "count-down",
|
||||||
|
/** Whether the timer counts to the end */
|
||||||
|
"countToEnd": false,
|
||||||
|
/** Whether the event is skipped */
|
||||||
|
"skip": false,
|
||||||
|
/** Note associated with the event */
|
||||||
|
"note": "Liam Carter, Sophia Patel + PowerPoint",
|
||||||
|
/** Colour code for the event */
|
||||||
|
"colour": "#77C785",
|
||||||
|
/** Current delay inherited from the rundown schedule */
|
||||||
|
"delay": 0,
|
||||||
|
/** Day offset for the event */
|
||||||
|
"dayOffset": 0,
|
||||||
|
/** Time gap between events */
|
||||||
|
"gap": 0,
|
||||||
|
/** Cue number for the event */
|
||||||
|
"cue": "1.2",
|
||||||
|
/** Parent group ID */
|
||||||
|
"parent": "7eaf99",
|
||||||
|
/** Revision number for the entry */
|
||||||
|
"revision": 0,
|
||||||
|
/** Warning time */
|
||||||
|
"timeWarning": 120000,
|
||||||
|
/** Danger time */
|
||||||
|
"timeDanger": 60000,
|
||||||
|
/** Custom fields for the event */
|
||||||
|
"custom": {},
|
||||||
|
/** Triggers associated with the event */
|
||||||
|
"triggers": [],
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Current group data */
|
||||||
|
"groupNow": {
|
||||||
|
/** Unique identifier for the group */
|
||||||
|
"id": "7eaf99",
|
||||||
|
/** Entry type */
|
||||||
|
"type": "group",
|
||||||
|
/** Title of the group */
|
||||||
|
"title": "Morning Sessions",
|
||||||
|
/** Note associated with the group */
|
||||||
|
"note": "",
|
||||||
|
/** ID of entries nested in the group */
|
||||||
|
"entries": ["9bf60f", "bf71a2", "c2697f", "fa593e", "a8b0b3"],
|
||||||
|
/** Optional, user defined target duration */
|
||||||
|
"targetDuration": null,
|
||||||
|
/** Colour code for the group */
|
||||||
|
"colour": "#339E4E",
|
||||||
|
/** Custom fields for the group */
|
||||||
|
"custom": {},
|
||||||
|
/** Revision number for the entry */
|
||||||
|
"revision": 0,
|
||||||
|
/** Timestamp of the first event's planned start time */
|
||||||
|
"timeStart": 36000000,
|
||||||
|
/** Timestamp of the last event's planned end time */
|
||||||
|
"timeEnd": 43200000,
|
||||||
|
/** Accumulated events duration */
|
||||||
|
"duration": 7200000,
|
||||||
|
/** Whether the first event has its start time linked */
|
||||||
|
"isFirstLinked": false,
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Message object with data */
|
||||||
|
"message": {
|
||||||
|
/** Timer view message data */
|
||||||
|
"timer": {
|
||||||
|
/** Text associated with the timer view */
|
||||||
|
"text": "",
|
||||||
|
/** Whether the message is visible */
|
||||||
|
"visible": false,
|
||||||
|
/** Whether the timer view is blinking */
|
||||||
|
"blink": false,
|
||||||
|
/** Whether the timer view is blacked out */
|
||||||
|
"blackout": false,
|
||||||
|
/** Secondary source for the view */
|
||||||
|
"secondarySource": null,
|
||||||
|
},
|
||||||
|
/** Secondary message text */
|
||||||
|
"secondary": "",
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Auxiliary timer 1 */
|
||||||
|
"auxtimer1": {
|
||||||
|
/** Duration of the timer */
|
||||||
|
"duration": 300000,
|
||||||
|
/** Current timer value */
|
||||||
|
"current": 300000,
|
||||||
|
/** Playback state (e.g., play, pause, stop) */
|
||||||
|
"playback": "stop",
|
||||||
|
/** Direction of the timer */
|
||||||
|
"direction": "count-down",
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Auxiliary timer 2 */
|
||||||
|
"auxtimer2": {
|
||||||
|
/** Duration of the timer */
|
||||||
|
"duration": 300000,
|
||||||
|
/** Current timer value */
|
||||||
|
"current": 300000,
|
||||||
|
/** Playback state (e.g., play, pause, stop) */
|
||||||
|
"playback": "stop",
|
||||||
|
/** Direction of the timer */
|
||||||
|
"direction": "count-down",
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Auxiliary timer 3 */
|
||||||
|
"auxtimer3": {
|
||||||
|
/** Duration of the timer */
|
||||||
|
"duration": 300000,
|
||||||
|
/** Current timer value */
|
||||||
|
"current": 300000,
|
||||||
|
/** Playback state (e.g., play, pause, stop) */
|
||||||
|
"playback": "stop",
|
||||||
|
/** Direction of the timer */
|
||||||
|
"direction": "count-down",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- [Ontime Documentation](https://docs.getontime.no)
|
||||||
|
- [GitHub Repository](https://github.com/getontime/ontime)
|
||||||
|
- [Runtime data reference](https://docs.getontime.no/api/data/runtime-data/)
|
||||||
|
|||||||
-405
@@ -1,405 +0,0 @@
|
|||||||
## Demo
|
|
||||||
|
|
||||||
This is a demo application which demonstrates how to create a custom view leveraging a websocket client to get data from Ontime.
|
|
||||||
|
|
||||||
Here, we subscribe to the websocket and display all the data received in a grid.
|
|
||||||
|
|
||||||
Please note this demo tries to be simple and clear. You would likely want to implement a more robust solution in a production environment.
|
|
||||||
|
|
||||||
### Getting the data
|
|
||||||
|
|
||||||
To subscribe to the websocket you will need:
|
|
||||||
|
|
||||||
- The address of the Ontime server (including the IP): eg, `cloud.getontime.no/stage-hash` or `192.168.1.1:4001`
|
|
||||||
- If the stage is password protected, you will also need to provide a token to access the data. You can get this token by generating a share link for Companion (Editor > Settings > Share link) and ensuring the "Authenticate Link" option is on.
|
|
||||||
|
|
||||||
#### Example
|
|
||||||
|
|
||||||
- Ontime URL: `https://cloud.getontime.no/stage-123`
|
|
||||||
- Ontime token: `token-from-share`
|
|
||||||
|
|
||||||
```js
|
|
||||||
// use wss since we are connecting to an https address
|
|
||||||
const socketUrl = `wss://cloud.getontime.no/stage-123/ws?token=token-from-share`;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Connects to the websocket server
|
|
||||||
* NOTE: this demo does not handle reconnections or errors
|
|
||||||
* @param {string} socketUrl
|
|
||||||
*/
|
|
||||||
const connectSocket = (socketUrl) => {
|
|
||||||
const websocket = new WebSocket(socketUrl);
|
|
||||||
|
|
||||||
websocket.onmessage = (event) => {
|
|
||||||
// all objects from ontime are structured with tag and payload
|
|
||||||
const { tag, payload } = JSON.parse(event.data);
|
|
||||||
|
|
||||||
// runtime-data is sent on connect, with the full state
|
|
||||||
// runtime-patch is sent on every change to the state
|
|
||||||
if (tag === 'runtime-data') {
|
|
||||||
handleOntimePayload(payload);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Runtime data
|
|
||||||
|
|
||||||
`runtime-data` contains a patch of all the data in the server
|
|
||||||
you would need to create a function that parses the patch and extract the data you need
|
|
||||||
|
|
||||||
In our case, we simply map the data to a DOM element with the same ID as the field name.
|
|
||||||
|
|
||||||
[See the docs](https://docs.getontime.no/api/data/runtime-data/).
|
|
||||||
|
|
||||||
#### Example of handling the payload
|
|
||||||
|
|
||||||
```js
|
|
||||||
const handleOntimePayload = (payload) => {
|
|
||||||
// 1. apply the patch into your local copy of the data
|
|
||||||
localData = { ...localData, ...payload };
|
|
||||||
|
|
||||||
// 2. update the UI with the new data
|
|
||||||
// ... timer data
|
|
||||||
if ('clock' in payload) updateDOM('clock', formatTimer(payload.clock));
|
|
||||||
if ('timer' in payload) updateDOM('timer', formatObject(payload.timer));
|
|
||||||
// ... rundown data
|
|
||||||
if ('rundown' in payload) updateDOM('rundown', formatObject(payload.rundown));
|
|
||||||
// ... runtime
|
|
||||||
if ('offset' in payload) updateDOM('offset', formatObject(payload.offset));
|
|
||||||
// ... relevant entries
|
|
||||||
if ('eventNow' in payload) updateDOM('eventNow', formatObject(payload.eventNow));
|
|
||||||
if ('eventNext' in payload) updateDOM('eventNext', formatObject(payload.eventNext));
|
|
||||||
if ('eventFlag' in payload) updateDOM('eventFlag', formatObject(payload.eventFlag));
|
|
||||||
if ('groupNow' in payload) updateDOM('groupNow', formatObject(payload.groupNow));
|
|
||||||
// ... messages service
|
|
||||||
if ('message' in payload) updateDOM('message', formatObject(payload.message));
|
|
||||||
// ... extra timers
|
|
||||||
if ('auxtimer1' in payload) updateDOM('auxtimer1', formatObject(payload.auxtimer1));
|
|
||||||
if ('auxtimer2' in payload) updateDOM('auxtimer2', formatObject(payload.auxtimer2));
|
|
||||||
if ('auxtimer3' in payload) updateDOM('auxtimer3', formatObject(payload.auxtimer3));
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Payload example
|
|
||||||
|
|
||||||
See below what the payload looks like.
|
|
||||||
Note: all timer values are in milliseconds.
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
/** Current server clock value */
|
|
||||||
"clock": 37816011,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gathers the current running timer state
|
|
||||||
*/
|
|
||||||
"timer": {
|
|
||||||
/** Additional time added to the running timer, can be negative */
|
|
||||||
"addedTime": 0,
|
|
||||||
/** Current running timer countdown */
|
|
||||||
"current": 3574976,
|
|
||||||
/** Total duration of the running event */
|
|
||||||
"duration": 3600000,
|
|
||||||
/** Time elapsed since the timer started */
|
|
||||||
"elapsed": 25024,
|
|
||||||
/** Timestamp of the expected finish time */
|
|
||||||
"expectedFinish": 41391285,
|
|
||||||
/** Current phase of the running event */
|
|
||||||
"phase": "default",
|
|
||||||
/** Timer's playback state */
|
|
||||||
"playback": "play",
|
|
||||||
/** Secondary timer, used to count to an event start in roll mode */
|
|
||||||
"secondaryTimer": null,
|
|
||||||
/** Timestamp when the timer started */
|
|
||||||
"startedAt": 37791285,
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Offset represents our current position in relation to the planned time
|
|
||||||
* a positive value means that we have added extra time to the expected end
|
|
||||||
* aka behind schedule
|
|
||||||
*/
|
|
||||||
"offset": {
|
|
||||||
/** Current absolute offset: accounts for planned times */
|
|
||||||
"absolute": 40394840,
|
|
||||||
/** Current relative offset: only counts for generated offset since start */
|
|
||||||
"relative": -35997119,
|
|
||||||
/** Currently selected offset mode */
|
|
||||||
"mode": "absolute",
|
|
||||||
/** Timestamp of the expected start of the next flag */
|
|
||||||
"expectedFlagStart": 80594840,
|
|
||||||
/** Timestamp of the expected end of the current group */
|
|
||||||
"expectedGroupEnd": 83594840,
|
|
||||||
/** Timestamp of the expected end of the loaded rundown */
|
|
||||||
"expectedRundownEnd": 90794840,
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Data object describes rundown schedule and the current progress */
|
|
||||||
"rundown": {
|
|
||||||
/** Index of the currently selected event */
|
|
||||||
"selectedEventIndex": 1,
|
|
||||||
/** Total number of events */
|
|
||||||
"numEvents": 7,
|
|
||||||
/** Timestamp of the rundown's planned start time */
|
|
||||||
"plannedStart": 0,
|
|
||||||
/** Timestamp of the rundown's planned end time */
|
|
||||||
"plannedEnd": 50400000,
|
|
||||||
/** Timestamp of when the rundown was actually started */
|
|
||||||
"actualStart": 76391959,
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Data of currently loaded event */
|
|
||||||
"eventNow": {
|
|
||||||
/** Unique identifier for the event */
|
|
||||||
"id": "9bf60f",
|
|
||||||
/** Entry type */
|
|
||||||
"type": "event",
|
|
||||||
/** Whether the event is flagged */
|
|
||||||
"flag": false,
|
|
||||||
/** Title of the event */
|
|
||||||
"title": "Pre-show Countdown",
|
|
||||||
/** Timestamp of the planned start time */
|
|
||||||
"timeStart": 36000000,
|
|
||||||
/** Timestamp of the planned end time */
|
|
||||||
"timeEnd": 39600000,
|
|
||||||
/** Planned event duration */
|
|
||||||
"duration": 3600000,
|
|
||||||
/** Strategy for time management */
|
|
||||||
"timeStrategy": "lock-end",
|
|
||||||
/** Whether the event is linked to the start of the previous */
|
|
||||||
"linkStart": false,
|
|
||||||
/** Action to take at the end of the event */
|
|
||||||
"endAction": "none",
|
|
||||||
/** Type of timer used for the event */
|
|
||||||
"timerType": "count-down",
|
|
||||||
/** Whether the timer counts to the end */
|
|
||||||
"countToEnd": false,
|
|
||||||
/** Whether the event is skipped */
|
|
||||||
"skip": false,
|
|
||||||
/** Note associated with the event */
|
|
||||||
"note": "Music plays, holding slide on screens",
|
|
||||||
/** Colour code for the event */
|
|
||||||
"colour": "#77C785",
|
|
||||||
/** Current delay inherited from the rundown schedule */
|
|
||||||
"delay": 0,
|
|
||||||
/** Day offset for the event */
|
|
||||||
"dayOffset": 0,
|
|
||||||
/** Time gap between events */
|
|
||||||
"gap": 0,
|
|
||||||
/** Cue number for the event */
|
|
||||||
"cue": "1",
|
|
||||||
/** Parent group ID */
|
|
||||||
"parent": "7eaf99",
|
|
||||||
/** Revision number for the entry */
|
|
||||||
"revision": 0,
|
|
||||||
/** Warning time */
|
|
||||||
"timeWarning": 600000,
|
|
||||||
/** Danger time */
|
|
||||||
"timeDanger": 300000,
|
|
||||||
/** Custom fields for the event */
|
|
||||||
"custom": { "Custom_Field": "Put additional info here" },
|
|
||||||
/** Triggers associated with the event */
|
|
||||||
"triggers": [],
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Upcoming event data */
|
|
||||||
"eventNext": {
|
|
||||||
/** Unique identifier for the event */
|
|
||||||
"id": "c2697f",
|
|
||||||
/** Entry type */
|
|
||||||
"type": "event",
|
|
||||||
/** Whether the event is flagged */
|
|
||||||
"flag": false,
|
|
||||||
/** Title of the event */
|
|
||||||
"title": "Welcome",
|
|
||||||
/** Timestamp of the planned start time */
|
|
||||||
"timeStart": 39600000,
|
|
||||||
/** Timestamp of the planned end time */
|
|
||||||
"timeEnd": 40200000,
|
|
||||||
/** Planned event duration */
|
|
||||||
"duration": 600000,
|
|
||||||
/** Strategy for time management */
|
|
||||||
"timeStrategy": "lock-duration",
|
|
||||||
/** Whether the event is linked to the start of the previous */
|
|
||||||
"linkStart": true,
|
|
||||||
/** Action to take at the end of the event */
|
|
||||||
"endAction": "none",
|
|
||||||
/** Type of timer used for the event */
|
|
||||||
"timerType": "count-down",
|
|
||||||
/** Whether the timer counts to the end */
|
|
||||||
"countToEnd": false,
|
|
||||||
/** Whether the event is skipped */
|
|
||||||
"skip": false,
|
|
||||||
/** Note associated with the event */
|
|
||||||
"note": "Emma Thompson",
|
|
||||||
/** Colour code for the event */
|
|
||||||
"colour": "#FFCC78",
|
|
||||||
/** Current delay inherited from the rundown schedule */
|
|
||||||
"delay": 0,
|
|
||||||
/** Day offset for the event */
|
|
||||||
"dayOffset": 0,
|
|
||||||
/** Time gap between events */
|
|
||||||
"gap": 0,
|
|
||||||
/** Cue number for the event */
|
|
||||||
"cue": "1.1",
|
|
||||||
/** Parent group ID */
|
|
||||||
"parent": "7eaf99",
|
|
||||||
/** Revision number for the entry */
|
|
||||||
"revision": 0,
|
|
||||||
/** Warning time */
|
|
||||||
"timeWarning": 120000,
|
|
||||||
/** Danger time */
|
|
||||||
"timeDanger": 60000,
|
|
||||||
/** Custom fields for the event */
|
|
||||||
"custom": {},
|
|
||||||
/** Triggers associated with the event */
|
|
||||||
"triggers": [],
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Data of currently targetted flag event */
|
|
||||||
"eventFlag": {
|
|
||||||
/** Unique identifier for the event */
|
|
||||||
"id": "fa593e",
|
|
||||||
/** Entry type */
|
|
||||||
"type": "event",
|
|
||||||
/** Whether the event is flagged */
|
|
||||||
"flag": true,
|
|
||||||
/** Title of the event */
|
|
||||||
"title": "Session 1",
|
|
||||||
/** Timestamp of the planned start time */
|
|
||||||
"timeStart": 40200000,
|
|
||||||
/** Timestamp of the planned end time */
|
|
||||||
"timeEnd": 43200000,
|
|
||||||
/** Planned event duration */
|
|
||||||
"duration": 3000000,
|
|
||||||
/** Strategy for time management */
|
|
||||||
"timeStrategy": "lock-duration",
|
|
||||||
/** Whether the event is linked to the start of the previous */
|
|
||||||
"linkStart": true,
|
|
||||||
/** Action to take at the end of the event */
|
|
||||||
"endAction": "none",
|
|
||||||
/** Type of timer used for the event */
|
|
||||||
"timerType": "count-down",
|
|
||||||
/** Whether the timer counts to the end */
|
|
||||||
"countToEnd": false,
|
|
||||||
/** Whether the event is skipped */
|
|
||||||
"skip": false,
|
|
||||||
/** Note associated with the event */
|
|
||||||
"note": "Liam Carter, Sophia Patel + PowerPoint",
|
|
||||||
/** Colour code for the event */
|
|
||||||
"colour": "#77C785",
|
|
||||||
/** Current delay inherited from the rundown schedule */
|
|
||||||
"delay": 0,
|
|
||||||
/** Day offset for the event */
|
|
||||||
"dayOffset": 0,
|
|
||||||
/** Time gap between events */
|
|
||||||
"gap": 0,
|
|
||||||
/** Cue number for the event */
|
|
||||||
"cue": "1.2",
|
|
||||||
/** Parent group ID */
|
|
||||||
"parent": "7eaf99",
|
|
||||||
/** Revision number for the entry */
|
|
||||||
"revision": 0,
|
|
||||||
/** Warning time */
|
|
||||||
"timeWarning": 120000,
|
|
||||||
/** Danger time */
|
|
||||||
"timeDanger": 60000,
|
|
||||||
/** Custom fields for the event */
|
|
||||||
"custom": {},
|
|
||||||
/** Triggers associated with the event */
|
|
||||||
"triggers": [],
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Current group data */
|
|
||||||
"groupNow": {
|
|
||||||
/** Unique identifier for the group */
|
|
||||||
"id": "7eaf99",
|
|
||||||
/** Entry type */
|
|
||||||
"type": "group",
|
|
||||||
/** Title of the group */
|
|
||||||
"title": "Morning Sessions",
|
|
||||||
/** Note associated with the group */
|
|
||||||
"note": "",
|
|
||||||
/** ID of entries nested in the group */
|
|
||||||
"entries": ["9bf60f", "bf71a2", "c2697f", "fa593e", "a8b0b3"],
|
|
||||||
/** Optional, user defined target duration */
|
|
||||||
"targetDuration": null,
|
|
||||||
/** Colour code for the group */
|
|
||||||
"colour": "#339E4E",
|
|
||||||
/** Custom fields for the group */
|
|
||||||
"custom": {},
|
|
||||||
/** Revision number for the entry */
|
|
||||||
"revision": 0,
|
|
||||||
/** Timestamp of the first event's planned start time */
|
|
||||||
"timeStart": 36000000,
|
|
||||||
/** Timestamp of the last event's planned end time */
|
|
||||||
"timeEnd": 43200000,
|
|
||||||
/** Accumulated events duration */
|
|
||||||
"duration": 7200000,
|
|
||||||
/** Whether the first event has its start time linked */
|
|
||||||
"isFirstLinked": false,
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Message object with data */
|
|
||||||
"message": {
|
|
||||||
/** Timer view message data */
|
|
||||||
"timer": {
|
|
||||||
/** Text associated with the timer view */
|
|
||||||
"text": "",
|
|
||||||
/** Whether the message is visible */
|
|
||||||
"visible": false,
|
|
||||||
/** Whether the timer view is blinking */
|
|
||||||
"blink": false,
|
|
||||||
/** Whether the timer view is blacked out */
|
|
||||||
"blackout": false,
|
|
||||||
/** Secondary source for the view */
|
|
||||||
"secondarySource": null,
|
|
||||||
},
|
|
||||||
/** Secondary message text */
|
|
||||||
"secondary": "",
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Auxiliary timer 1 */
|
|
||||||
"auxtimer1": {
|
|
||||||
/** Duration of the timer */
|
|
||||||
"duration": 300000,
|
|
||||||
/** Current timer value */
|
|
||||||
"current": 300000,
|
|
||||||
/** Playback state (e.g., play, pause, stop) */
|
|
||||||
"playback": "stop",
|
|
||||||
/** Direction of the timer */
|
|
||||||
"direction": "count-down",
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Auxiliary timer 2 */
|
|
||||||
"auxtimer2": {
|
|
||||||
/** Duration of the timer */
|
|
||||||
"duration": 300000,
|
|
||||||
/** Current timer value */
|
|
||||||
"current": 300000,
|
|
||||||
/** Playback state (e.g., play, pause, stop) */
|
|
||||||
"playback": "stop",
|
|
||||||
/** Direction of the timer */
|
|
||||||
"direction": "count-down",
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Auxiliary timer 3 */
|
|
||||||
"auxtimer3": {
|
|
||||||
/** Duration of the timer */
|
|
||||||
"duration": 300000,
|
|
||||||
/** Current timer value */
|
|
||||||
"current": 300000,
|
|
||||||
/** Playback state (e.g., play, pause, stop) */
|
|
||||||
"playback": "stop",
|
|
||||||
/** Direction of the timer */
|
|
||||||
"direction": "count-down",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Links
|
|
||||||
|
|
||||||
- [Ontime Documentation](https://docs.getontime.no)
|
|
||||||
- [GitHub Repository](https://github.com/getontime/ontime)
|
|
||||||
- [Runtime data reference](https://docs.getontime.no/api/data/runtime-data/)
|
|
||||||
Vendored
-157
@@ -1,157 +0,0 @@
|
|||||||
/*eslint-env browser*/
|
|
||||||
/**
|
|
||||||
* This is a very minimal example for a websocket client
|
|
||||||
* You could use this as a starting point to creating your own interfaces
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Data that the user needs to provide depending on the Ontime URL
|
|
||||||
const isSecure = window.location.protocol === 'https:';
|
|
||||||
const userProvidedSocketUrl = `${isSecure ? 'wss' : 'ws'}://${window.location.host}${getStageHash()}/ws`;
|
|
||||||
|
|
||||||
connectSocket();
|
|
||||||
|
|
||||||
let reconnectTimeout;
|
|
||||||
const reconnectInterval = 1000;
|
|
||||||
let reconnectAttempts = 0;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Connects to the websocket server
|
|
||||||
* @param {string} socketUrl
|
|
||||||
*/
|
|
||||||
function connectSocket(socketUrl = userProvidedSocketUrl) {
|
|
||||||
const websocket = new WebSocket(socketUrl);
|
|
||||||
|
|
||||||
websocket.onopen = () => {
|
|
||||||
clearTimeout(reconnectTimeout);
|
|
||||||
reconnectAttempts = 0;
|
|
||||||
console.warn('WebSocket connected');
|
|
||||||
};
|
|
||||||
|
|
||||||
websocket.onclose = () => {
|
|
||||||
console.warn('WebSocket disconnected');
|
|
||||||
reconnectTimeout = setTimeout(() => {
|
|
||||||
console.warn(`WebSocket: attempting reconnect ${reconnectAttempts}`);
|
|
||||||
if (websocket && websocket.readyState === WebSocket.CLOSED) {
|
|
||||||
reconnectAttempts += 1;
|
|
||||||
connectSocket();
|
|
||||||
}
|
|
||||||
}, reconnectInterval);
|
|
||||||
};
|
|
||||||
websocket.onerror = (error) => {
|
|
||||||
console.error('WebSocket error:', error);
|
|
||||||
};
|
|
||||||
|
|
||||||
websocket.onmessage = (event) => {
|
|
||||||
// all objects from ontime are structured with tag and payload
|
|
||||||
const { tag, payload } = JSON.parse(event.data);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* runtime-data is sent
|
|
||||||
* - on connect with the full state
|
|
||||||
* - and then on every update with a patch
|
|
||||||
*/
|
|
||||||
if (tag === 'runtime-data') {
|
|
||||||
handleOntimePayload(payload);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let localData = {};
|
|
||||||
/**
|
|
||||||
* Handles the ontime payload updates
|
|
||||||
* @param {object} payload - The payload object containing the updates
|
|
||||||
*/
|
|
||||||
function handleOntimePayload(payload) {
|
|
||||||
// 1. apply the patch into your local copy of the data
|
|
||||||
localData = { ...localData, ...payload };
|
|
||||||
|
|
||||||
// 2. update the UI with the new data
|
|
||||||
// ... timer data
|
|
||||||
if ('clock' in payload) updateDOM('clock', formatTimer(payload.clock));
|
|
||||||
if ('timer' in payload) updateDOM('timer', formatObject(payload.timer));
|
|
||||||
// ... rundown data
|
|
||||||
if ('rundown' in payload) updateDOM('rundown', formatObject(payload.rundown));
|
|
||||||
// ... runtime
|
|
||||||
if ('offset' in payload) updateDOM('offset', formatObject(payload.offset));
|
|
||||||
// ... relevant entries
|
|
||||||
if ('eventNow' in payload) updateDOM('eventNow', formatObject(payload.eventNow));
|
|
||||||
if ('eventNext' in payload) updateDOM('eventNext', formatObject(payload.eventNext));
|
|
||||||
if ('eventFlag' in payload) updateDOM('eventFlag', formatObject(payload.eventFlag));
|
|
||||||
if ('groupNow' in payload) updateDOM('groupNow', formatObject(payload.groupNow));
|
|
||||||
// ... messages service
|
|
||||||
if ('message' in payload) updateDOM('message', formatObject(payload.message));
|
|
||||||
// ... extra timers
|
|
||||||
if ('auxtimer1' in payload) updateDOM('auxtimer1', formatObject(payload.auxtimer1));
|
|
||||||
if ('auxtimer2' in payload) updateDOM('auxtimer2', formatObject(payload.auxtimer2));
|
|
||||||
if ('auxtimer3' in payload) updateDOM('auxtimer3', formatObject(payload.auxtimer3));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates the DOM with a given payload
|
|
||||||
* @param {string} field - The runtime data field
|
|
||||||
* @param {object} payload - The patch object for the field
|
|
||||||
*/
|
|
||||||
function updateDOM(field, payload) {
|
|
||||||
const domElement = document.getElementById(field);
|
|
||||||
if (domElement) {
|
|
||||||
domElement.innerText = payload;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Time constants used for calculating times
|
|
||||||
const millisToSeconds = 1000;
|
|
||||||
const millisToMinutes = 1000 * 60;
|
|
||||||
const millisToHours = 1000 * 60 * 60;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Formats a timer value into a human-readable string
|
|
||||||
* @param {number} number - The timer value in milliseconds
|
|
||||||
* @returns {string} The formatted timer string
|
|
||||||
*/
|
|
||||||
function formatTimer(number) {
|
|
||||||
if (number == null) {
|
|
||||||
return '--:--:--';
|
|
||||||
}
|
|
||||||
const millis = Math.abs(number);
|
|
||||||
const isNegative = number < 0;
|
|
||||||
return `${isNegative ? '-' : ''}${leftPad(millis / millisToHours)}:${leftPad(
|
|
||||||
(millis % millisToHours) / millisToMinutes,
|
|
||||||
)}:${leftPad((millis % millisToMinutes) / millisToSeconds)}`;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pads a number with leading zeros
|
|
||||||
* @param {number} number - The number to pad
|
|
||||||
* @returns {string} The padded number string
|
|
||||||
*/
|
|
||||||
function leftPad(val) {
|
|
||||||
return Math.floor(val).toString().padStart(2, '0');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stringifies an object into a pretty string
|
|
||||||
* @param {object} data - The data object to format
|
|
||||||
* @returns {string} The formatted data string
|
|
||||||
*/
|
|
||||||
function formatObject(data) {
|
|
||||||
return JSON.stringify(data, null, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility to handle a demo deployed in an ontime stage
|
|
||||||
* You can likely ignore this in your app
|
|
||||||
*
|
|
||||||
* an url looks like
|
|
||||||
* https://cloud.getontime.no/stage-hash/external/demo/ -> /stage-hash
|
|
||||||
* @returns {string} - The stage hash if the app is running in an ontime stage
|
|
||||||
*/
|
|
||||||
function getStageHash() {
|
|
||||||
const href = window.location.href;
|
|
||||||
if (!href.includes('getontime.no')) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const hash = href.split('/');
|
|
||||||
const stageHash = hash.at(3);
|
|
||||||
return stageHash ? `/${stageHash}` : '';
|
|
||||||
}
|
|
||||||
-116
@@ -1,116 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<!-- For detailed explanations and examples, refer to the README.md file in this directory -->
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
|
|
||||||
<title>ontime demo</title>
|
|
||||||
<link href="./styles.css" rel="stylesheet" />
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<header class="title-card">
|
|
||||||
<div class="logo-title">
|
|
||||||
<img
|
|
||||||
src="https://www.getontime.no/images/icons/ontime-logo.png"
|
|
||||||
alt="Ontime logo"
|
|
||||||
onerror="this.style.display = 'none'"
|
|
||||||
/>
|
|
||||||
<h1 class="title">Ontime demo</h1>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>Last message received at</span>
|
|
||||||
<span id="clock">-</span>
|
|
||||||
</div>
|
|
||||||
<nav>
|
|
||||||
<a href="https://docs.getontime.no/api/data/runtime-data" target="_blank">Help? See docs</a>
|
|
||||||
<div>See <a href="README.md">README.md</a> details.</div>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="container">
|
|
||||||
<section class="column">
|
|
||||||
<details class="card" open>
|
|
||||||
<summary class="title">Timer</summary>
|
|
||||||
<figure>
|
|
||||||
<figcaption class="description">Current timer values</figcaption>
|
|
||||||
<code id="timer">-</code>
|
|
||||||
</figure>
|
|
||||||
</details>
|
|
||||||
<details class="card" open>
|
|
||||||
<summary class="title">Rundown</summary>
|
|
||||||
<figure>
|
|
||||||
<figcaption class="description">Progress of the current rundown</figcaption>
|
|
||||||
<code id="rundown">-</code>
|
|
||||||
</figure>
|
|
||||||
</details>
|
|
||||||
<details class="card" open>
|
|
||||||
<summary class="title">Offset</summary>
|
|
||||||
<figure>
|
|
||||||
<figcaption class="description">Runtime offset and timings for upcoming targets</figcaption>
|
|
||||||
<code id="offset">-</code>
|
|
||||||
</figure>
|
|
||||||
</details>
|
|
||||||
</section>
|
|
||||||
<section class="column">
|
|
||||||
<details class="card" open>
|
|
||||||
<summary class="title">Event now</summary>
|
|
||||||
<figure>
|
|
||||||
<figcaption class="description">Currently loaded event</figcaption>
|
|
||||||
<code id="eventNow">-</code>
|
|
||||||
</figure>
|
|
||||||
</details>
|
|
||||||
<details class="card" open>
|
|
||||||
<summary class="title">Event next</summary>
|
|
||||||
<figure>
|
|
||||||
<figcaption class="description">Next scheduled event</figcaption>
|
|
||||||
<code id="eventNext">-</code>
|
|
||||||
</figure>
|
|
||||||
</details>
|
|
||||||
</section>
|
|
||||||
<section class="column">
|
|
||||||
<details class="card" open>
|
|
||||||
<summary class="title">Group now</summary>
|
|
||||||
<figure>
|
|
||||||
<figcaption class="description">Currently active group</figcaption>
|
|
||||||
<code id="groupNow">-</code>
|
|
||||||
</figure>
|
|
||||||
</details>
|
|
||||||
<details class="card" open>
|
|
||||||
<summary class="title">Event flag</summary>
|
|
||||||
<figure>
|
|
||||||
<figcaption class="description">Currently targeted flag</figcaption>
|
|
||||||
<code id="eventFlag">-</code>
|
|
||||||
</figure>
|
|
||||||
</details>
|
|
||||||
</section>
|
|
||||||
<section class="column">
|
|
||||||
<details class="card" open>
|
|
||||||
<summary class="title">Message</summary>
|
|
||||||
<figure>
|
|
||||||
<figcaption class="description">Messaging feature</figcaption>
|
|
||||||
<code id="message">-</code>
|
|
||||||
</figure>
|
|
||||||
</details>
|
|
||||||
<details class="card" open>
|
|
||||||
<summary class="title">Aux timers</summary>
|
|
||||||
<figure>
|
|
||||||
<figcaption class="description">Auxiliary Timer 1</figcaption>
|
|
||||||
<code id="auxtimer1">-</code>
|
|
||||||
</figure>
|
|
||||||
<figure>
|
|
||||||
<figcaption class="description">Auxiliary Timer 2</figcaption>
|
|
||||||
<code id="auxtimer2">-</code>
|
|
||||||
</figure>
|
|
||||||
<figure>
|
|
||||||
<figcaption class="description">Auxiliary Timer 3</figcaption>
|
|
||||||
<code id="auxtimer3">-</code>
|
|
||||||
</figure>
|
|
||||||
</details>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<script src="./app.js" type="text/javascript"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
-91
@@ -1,91 +0,0 @@
|
|||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
max-width: 100vw;
|
|
||||||
overflow-x: hidden;
|
|
||||||
font-family: 'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.4;
|
|
||||||
background: #f6f6f6;
|
|
||||||
color: #222;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container .column {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title-card {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 10px 12px;
|
|
||||||
background: #eaeaea;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-title {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 7px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-title img {
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
padding: 10px 12px;
|
|
||||||
background: #fff;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
|
|
||||||
}
|
|
||||||
|
|
||||||
.card summary.title {
|
|
||||||
border-bottom: 1px solid #ccc;
|
|
||||||
padding-bottom: 2px;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1.title,
|
|
||||||
summary.title {
|
|
||||||
font-size: 0.95em;
|
|
||||||
font-weight: 600;
|
|
||||||
margin: 0;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
summary.title {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
code {
|
|
||||||
font-size: 0.75em;
|
|
||||||
font-family: monospace;
|
|
||||||
background: #f4f4f4;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 1.5px 3px;
|
|
||||||
display: inline-block;
|
|
||||||
white-space: pre;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
figure {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
figcaption.description {
|
|
||||||
color: #555;
|
|
||||||
font-size: 0.75em;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { copyFileSync, existsSync, writeFileSync } from 'fs';
|
import { copyFileSync, existsSync, writeFileSync } from 'fs';
|
||||||
|
|
||||||
import { defaultTranslation } from '../user/translations/bundledTranslations.js';
|
import { defaultTranslation } from '../bundle/bundledTranslations.js';
|
||||||
import { ensureDirectory } from '../utils/fileManagement.js';
|
import { ensureDirectory } from '../utils/fileManagement.js';
|
||||||
import { publicDir, publicFiles, srcFiles } from './index.js';
|
import { publicDir, publicFiles, srcFiles } from './index.js';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { PathLike, constants, existsSync, mkdirSync } from 'fs';
|
import { type Dirent, PathLike, type Stats, constants, existsSync, mkdirSync } from 'fs';
|
||||||
import { copyFile, readdir, unlink } from 'fs/promises';
|
import { access, copyFile, mkdir, readdir, rm, stat, unlink, writeFile } from 'fs/promises';
|
||||||
import { basename, join, parse } from 'path';
|
import { basename, join, parse } from 'path';
|
||||||
|
|
||||||
import { consoleError } from './console.js';
|
import { consoleError } from './console.js';
|
||||||
@@ -152,3 +152,77 @@ export const deleteFile = async (filePath: string) => {
|
|||||||
console.error('Could not delete file:', error);
|
console.error('Could not delete file:', error);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
||||||
|
return error instanceof Error && 'code' in error;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns file stats, or null if the path does not exist.
|
||||||
|
* Re-throws any error that is not ENOENT.
|
||||||
|
*/
|
||||||
|
export async function statIfExists(filePath: string): Promise<Stats | null> {
|
||||||
|
try {
|
||||||
|
return await stat(filePath);
|
||||||
|
} catch (error) {
|
||||||
|
if (isNodeError(error) && error.code === 'ENOENT') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively deletes a directory and all its contents.
|
||||||
|
* No-op if the directory does not exist.
|
||||||
|
*/
|
||||||
|
export async function deleteDirectory(directoryPath: string): Promise<void> {
|
||||||
|
await rm(directoryPath, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a directory if it exists and creates it fresh.
|
||||||
|
*/
|
||||||
|
export async function replaceDirectory(directoryPath: string): Promise<void> {
|
||||||
|
await rm(directoryPath, { recursive: true, force: true });
|
||||||
|
await mkdir(directoryPath, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a directory. Throws if it already exists.
|
||||||
|
*/
|
||||||
|
export async function createDirectory(directoryPath: string): Promise<void> {
|
||||||
|
await mkdir(directoryPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns directory entries with file type information.
|
||||||
|
*/
|
||||||
|
export async function readDirectoryEntries(directoryPath: string): Promise<Dirent[]> {
|
||||||
|
return readdir(directoryPath, { withFileTypes: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes content to a file, creating it if it does not exist.
|
||||||
|
*/
|
||||||
|
export async function writeToFile(
|
||||||
|
filePath: string,
|
||||||
|
content: string | Buffer,
|
||||||
|
options?: { encoding?: BufferEncoding },
|
||||||
|
): Promise<void> {
|
||||||
|
await writeFile(filePath, content, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the file exists and is readable, false if it does not exist.
|
||||||
|
* Re-throws any error that is not ENOENT.
|
||||||
|
*/
|
||||||
|
export async function fileIsReadable(filePath: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await access(filePath, constants.R_OK);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (isNodeError(error) && error.code === 'ENOENT') return false;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { readFile } from 'fs/promises';
|
||||||
|
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
const baseURL = 'http://localhost:4001';
|
||||||
|
const apiURL = `${baseURL}/data/custom-views`;
|
||||||
|
const testSlug = 'e2e-test-view';
|
||||||
|
const fixtureFile = 'e2e/tests/fixtures/custom-view-test.html';
|
||||||
|
|
||||||
|
test.describe('custom views', () => {
|
||||||
|
test.afterEach(async ({ request }) => {
|
||||||
|
try {
|
||||||
|
await request.delete(`${apiURL}/${testSlug}`);
|
||||||
|
} catch {
|
||||||
|
/** nothing to do here */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('upload, list, serve, and delete a custom view', async ({ page, request }) => {
|
||||||
|
// 1. Upload a custom view via the API
|
||||||
|
const fileContent = await readFile(fixtureFile);
|
||||||
|
const response = await request.post(`${apiURL}/${testSlug}/upload`, {
|
||||||
|
multipart: {
|
||||||
|
indexHtml: {
|
||||||
|
name: 'index.html',
|
||||||
|
mimeType: 'text/html',
|
||||||
|
buffer: fileContent,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(response.status()).toBe(201);
|
||||||
|
|
||||||
|
// 2. Verify the view appears in the listing
|
||||||
|
const listResponse = await request.get(apiURL);
|
||||||
|
expect(listResponse.ok()).toBeTruthy();
|
||||||
|
const listData = await listResponse.json();
|
||||||
|
const uploadedView = listData.views.find((v: { slug: string }) => v.slug === testSlug);
|
||||||
|
expect(uploadedView).toBeDefined();
|
||||||
|
|
||||||
|
// 3. Navigate to the custom view URL and verify it renders
|
||||||
|
await page.goto(`${baseURL}/external/${testSlug}/`);
|
||||||
|
await expect(page.getByTestId('custom-view-heading')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('custom-view-heading')).toHaveText('Custom View E2E Test');
|
||||||
|
|
||||||
|
// 4. Delete the view
|
||||||
|
const deleteResponse = await request.delete(`${apiURL}/${testSlug}`);
|
||||||
|
expect(deleteResponse.status()).toBe(204);
|
||||||
|
|
||||||
|
// 5. Verify the view is removed from the listing
|
||||||
|
const listAfterDelete = await request.get(apiURL);
|
||||||
|
const dataAfterDelete = await listAfterDelete.json();
|
||||||
|
const deletedView = dataAfterDelete.views.find((v: { slug: string }) => v.slug === testSlug);
|
||||||
|
expect(deletedView).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>Custom View E2E Test</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: sans-serif; padding: 2rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1 data-testid="custom-view-heading">Custom View E2E Test</h1>
|
||||||
|
<p>This is a test custom view for end-to-end testing.</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export interface CustomViewSummary {
|
||||||
|
slug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomViewsListResponse {
|
||||||
|
views: CustomViewSummary[];
|
||||||
|
}
|
||||||
@@ -91,6 +91,7 @@ export type {
|
|||||||
RundownSummary,
|
RundownSummary,
|
||||||
} from './api/rundown-controller/BackendResponse.type.js';
|
} from './api/rundown-controller/BackendResponse.type.js';
|
||||||
export type { LinkOptions } from './api/session-controller/BackendResponse.type.js';
|
export type { LinkOptions } from './api/session-controller/BackendResponse.type.js';
|
||||||
|
export type { CustomViewSummary, CustomViewsListResponse } from './api/custom-views/customViews.type.js';
|
||||||
|
|
||||||
// web socket
|
// web socket
|
||||||
export { MessageTag } from './api/websocket/data.type.js';
|
export { MessageTag } from './api/websocket/data.type.js';
|
||||||
|
|||||||
Reference in New Issue
Block a user