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:
Carlos Valente
2026-03-24 09:19:45 +01:00
committed by GitHub
parent 95536902f5
commit 7e3aaa8c30
39 changed files with 1799 additions and 817 deletions
@@ -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/&lt;name&gt;/</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 type { PanelBaseProps } from '../../panel-list/PanelList';
import * as Panel from '../../panel-utils/PanelUtils';
import CustomViews from '../manage-panel/CustomViews';
import GeneralSettings from './GeneralSettings';
import ProjectData from './ProjectData';
import ServerPortSettings from './ServerPortSettings';
@@ -10,8 +11,9 @@ import ViewSettings from './ViewSettings';
export default function SettingsPanel({ location }: PanelBaseProps) {
const dataRef = useScrollIntoView<HTMLDivElement>('data', location);
const generalRef = useScrollIntoView<HTMLDivElement>('general', location);
const portRef = useScrollIntoView<HTMLDivElement>('port', location);
const viewRef = useScrollIntoView<HTMLDivElement>('view', location);
const customViewsRef = useScrollIntoView<HTMLDivElement>('custom-views', location);
const portRef = useScrollIntoView<HTMLDivElement>('port', location);
return (
<>
@@ -25,6 +27,9 @@ export default function SettingsPanel({ location }: PanelBaseProps) {
<div ref={viewRef}>
<ViewSettings />
</div>
<div ref={customViewsRef}>
<CustomViews />
</div>
{!isDocker && (
<div ref={portRef}>
<ServerPortSettings />
@@ -19,7 +19,8 @@ const staticOptions = [
{ id: 'settings__data', label: 'Project data' },
{ id: 'settings__general', label: 'General 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' },
],
},
{