diff --git a/apps/client/package.json b/apps/client/package.json index 8c213bbb8..e5857fe15 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -1,6 +1,6 @@ { "name": "ontime-ui", - "version": "2.7.2", + "version": "2.8.1-rc-table", "private": true, "dependencies": { "@chakra-ui/react": "^2.7.0", diff --git a/apps/client/src/common/api/ontimeApi.ts b/apps/client/src/common/api/ontimeApi.ts index 299c8d3f0..9127d15d1 100644 --- a/apps/client/src/common/api/ontimeApi.ts +++ b/apps/client/src/common/api/ontimeApi.ts @@ -1,5 +1,14 @@ -import axios from 'axios'; -import { Alias, OSCSettings, OscSubscription, ProjectData, Settings, UserFields, ViewSettings } from 'ontime-types'; +import axios, { AxiosResponse } from 'axios'; +import { + Alias, + OntimeRundown, + OSCSettings, + OscSubscription, + ProjectData, + Settings, + UserFields, + ViewSettings, +} from 'ontime-types'; import { apiRepoLatest } from '../../externals'; import { InfoType } from '../models/Info'; @@ -161,20 +170,27 @@ export const uploadData = async (file: File, setProgress: (value: number) => voi .then((response) => response.data.id); }; +type Backend = { + rundown: OntimeRundown; + project: ProjectData; + userFields: UserFields; +}; + export async function postPreviewExcel(file: File, setProgress: (value: number) => void, options?: UploadDataOptions) { const formData = new FormData(); formData.append('userFile', file); - const response = await axios - .post(`${ontimeURL}/previewExcel`, formData, { - headers: { - 'Content-Type': 'multipart/form-data', - }, - onUploadProgress: (progressEvent) => { - const complete = progressEvent?.total ? Math.round((progressEvent.loaded * 100) / progressEvent.total) : 0; - setProgress(complete); - }, - }) - .then((response) => response); + formData.append('options', JSON.stringify(options)); + console.log('appending options', options); + + const response: AxiosResponse = await axios.post(`${ontimeURL}/previewExcel`, formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + onUploadProgress: (progressEvent) => { + const complete = progressEvent?.total ? Math.round((progressEvent.loaded * 100) / progressEvent.total) : 0; + setProgress(complete); + }, + }); return response; } diff --git a/apps/client/src/common/components/import-preview/PreviewColumn.module.scss b/apps/client/src/common/components/import-preview/PreviewColumn.module.scss index 12e1952e4..5befcd4b1 100644 --- a/apps/client/src/common/components/import-preview/PreviewColumn.module.scss +++ b/apps/client/src/common/components/import-preview/PreviewColumn.module.scss @@ -9,16 +9,14 @@ display: grid; grid-template-columns: auto 1fr; grid-template-rows: repeat(6, auto); - gap: 1px; font-size: calc(1rem - 2px); } .field { + font-weight: 200; @include pad-item; - background-color: $gray-100; } .value { @include pad-item; - background-color: $gray-50; } diff --git a/apps/client/src/common/components/import-preview/PreviewRundown.tsx b/apps/client/src/common/components/import-preview/PreviewRundown.tsx index a2e6d5cfd..56661bd4e 100644 --- a/apps/client/src/common/components/import-preview/PreviewRundown.tsx +++ b/apps/client/src/common/components/import-preview/PreviewRundown.tsx @@ -1,4 +1,5 @@ -import { isOntimeEvent, OntimeRundown } from 'ontime-types'; +import { ReactNode } from 'react'; +import { isOntimeEvent, OntimeRundown, UserFields } from 'ontime-types'; import { millisToString } from 'ontime-utils'; import { getAccessibleColour } from '../../utils/styleUtils'; @@ -7,13 +8,14 @@ import style from './PreviewTable.module.scss'; interface PreviewRundownProps { rundown: OntimeRundown; + userFields: UserFields; } function booleanToText(value?: boolean) { - return value ? 'Yes' : ''; + return value ? 'Yes' : undefined; } -export default function PreviewRundown({ rundown }: PreviewRundownProps) { +export default function PreviewRundown({ rundown, userFields }: PreviewRundownProps) { return (
@@ -35,16 +37,36 @@ export default function PreviewRundown({ rundown }: PreviewRundownProps) { Colour Timer Type End Action - user0 - user1 - user2 - user3 - user4 - user5 - user6 - user7 - user8 - user9 + + user0 {userFields.user0} + + + user1 {userFields.user1} + + + user2 {userFields.user2} + + + user3 {userFields.user3} + + + user4 {userFields.user4} + + + user5 {userFields.user5} + + + user6 {userFields.user6} + + + user7 {userFields.user7} + + + user8 {userFields.user8} + + + user9 {userFields.user9} + @@ -52,9 +74,13 @@ export default function PreviewRundown({ rundown }: PreviewRundownProps) { const key = event.id; if (isOntimeEvent(event)) { const colour = event.colour ? getAccessibleColour(event.colour) : {}; + const isPublic = booleanToText(event.isPublic); + const skip = booleanToText(event.skip); return ( - {index + 1} + + {index + 1} + Event {event.cue} {event.title} @@ -64,11 +90,15 @@ export default function PreviewRundown({ rundown }: PreviewRundownProps) { {millisToString(event.timeStart)} {millisToString(event.timeEnd)} {millisToString(event.duration)} - {booleanToText(event.isPublic)} - {booleanToText(event.skip)} + {isPublic && {isPublic}} + {skip && {skip}} {event.colour} - {event.timerType} - {event.endAction} + + {event.timerType} + + + {event.endAction} + {event.user0} {event.user1} {event.user2} @@ -90,3 +120,7 @@ export default function PreviewRundown({ rundown }: PreviewRundownProps) {
); } + +function Tag({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/apps/client/src/common/components/import-preview/PreviewTable.module.scss b/apps/client/src/common/components/import-preview/PreviewTable.module.scss index b4564e007..75df79794 100644 --- a/apps/client/src/common/components/import-preview/PreviewTable.module.scss +++ b/apps/client/src/common/components/import-preview/PreviewTable.module.scss @@ -2,7 +2,8 @@ .container { max-width: 100%; - max-height: 40vh; + max-height: max(300px, 30vh); + overflow: scroll; } .scrollContainer { @@ -11,6 +12,7 @@ .rundownPreview { font-size: calc(1rem - 2px); + border-collapse: separate; } .header, @@ -19,16 +21,35 @@ font-weight: 400; height: unset; line-height: calc(1rem - 2px); - text-align: left; + white-space: nowrap; + padding-left: 0.25rem; + padding-right: 1rem; } } .header { - background-color: $gray-100; + th { + font-weight: 200; + text-align: left; + } + + tr { + word-wrap: unset; + } } .body { - tr:nth-child(even) { + + tr:nth-child(odd) { background-color: $gray-50; } -} \ No newline at end of file +} + +.tag { + font-size: 10px; + background-color: $blue-500; + color: $pure-white; + border-radius: 2px; + padding: 0 0.25rem; + white-space: nowrap; +} diff --git a/apps/client/src/features/modals/Modal.module.scss b/apps/client/src/features/modals/Modal.module.scss index 10a97436b..aa2576fbe 100644 --- a/apps/client/src/features/modals/Modal.module.scss +++ b/apps/client/src/features/modals/Modal.module.scss @@ -41,8 +41,8 @@ $el-padding-with-compensation: 24px; // 16 + 8 .title { font-size: $inner-section-text-size; color: $gray-500; - padding-left: 8px; - margin: 8px 0; + padding-left: 0.5rem; + margin: 0.5rem 0; text-transform: uppercase; } diff --git a/apps/client/src/features/modals/upload-modal/CollapsableSection.module.scss b/apps/client/src/features/modals/upload-modal/CollapsableSection.module.scss new file mode 100644 index 000000000..fd9570183 --- /dev/null +++ b/apps/client/src/features/modals/upload-modal/CollapsableSection.module.scss @@ -0,0 +1,22 @@ +@use '../../../theme/v2Styles' as *; +@use '../../../theme/ontimeColours' as *; + +.header { + font-size: $inner-section-text-size; + color: $gray-500; + padding-left: 0.5rem; + margin: 0.5rem 0; + text-transform: uppercase; + + display: flex; +} + +.moreExpanded { + transform: scaleY(-1); + transition: transform $transition-time-feedback; +} + +.moreCollapsed { + transform: scaleY(1); + transition: transform $transition-time-feedback; +} diff --git a/apps/client/src/features/modals/upload-modal/CollapsableSection.tsx b/apps/client/src/features/modals/upload-modal/CollapsableSection.tsx new file mode 100644 index 000000000..f494f30f4 --- /dev/null +++ b/apps/client/src/features/modals/upload-modal/CollapsableSection.tsx @@ -0,0 +1,23 @@ +import { PropsWithChildren, useState } from 'react'; +import { IoChevronUp } from '@react-icons/all-files/io5/IoChevronUp'; + +import style from './CollapsableSection.module.scss'; + +interface CollapsableSectionProps { + title: string; +} + +export default function CollapsableSection(props: PropsWithChildren) { + const { title, children } = props; + const [collapsed, setCollapsed] = useState(false); + + return ( + <> +
setCollapsed((prev) => !prev)}> + {title} + +
+ {!collapsed && children} + + ); +} diff --git a/apps/client/src/features/modals/upload-modal/ReviewExcel.tsx b/apps/client/src/features/modals/upload-modal/ReviewExcel.tsx new file mode 100644 index 000000000..05a05d20c --- /dev/null +++ b/apps/client/src/features/modals/upload-modal/ReviewExcel.tsx @@ -0,0 +1,25 @@ +import { OntimeRundown, ProjectData, UserFields } from 'ontime-types'; + +import PreviewProjectData from '../../../common/components/import-preview/PreviewProjectData'; +import PreviewRundown from '../../../common/components/import-preview/PreviewRundown'; + +import style from '../Modal.module.scss'; + +interface ReviewFileProps { + rundown: OntimeRundown; + project: ProjectData; + userFields: UserFields; +} + +export default function ReviewFile(props: ReviewFileProps) { + const { rundown, project, userFields } = props; + + return ( +
+
Review Project Data
+ +
Review Rundown
+ +
+ ); +} diff --git a/apps/client/src/features/modals/upload-modal/UploadFile.tsx b/apps/client/src/features/modals/upload-modal/UploadFile.tsx new file mode 100644 index 000000000..958aba9ed --- /dev/null +++ b/apps/client/src/features/modals/upload-modal/UploadFile.tsx @@ -0,0 +1,58 @@ +import { ChangeEvent, useRef, useState } from 'react'; +import { Input } from '@chakra-ui/react'; + +import UploadEntry from './upload-entry/UploadEntry'; +import { useUploadModalContextStore } from './uploadModalContext'; +import { validateFile } from './uploadUtils'; + +import style from './UploadModal.module.scss'; + +export default function UploadFile() { + const fileInputRef = useRef(null); + + const { file, setFile, progress } = useUploadModalContextStore(); + + const [errors, setErrors] = useState(); + const success = false; + + const clearFile = () => { + setFile(null); + }; + + const handleFile = (event: ChangeEvent) => { + const fileSelected = event?.target?.files?.[0]; + if (!fileSelected) return; + + const validate = validateFile(fileSelected); + setErrors(validate.errors?.[0]); + + if (validate.isValid) { + setFile(fileSelected); + } else { + setFile(null); + } + }; + + const handleClick = () => { + fileInputRef.current?.click(); + }; + + return ( + <> + +
+ Click to upload Ontime project or xlsx file +
+ {(file || errors) && ( + + )} + + ); +} diff --git a/apps/client/src/features/modals/upload-modal/UploadModal.module.scss b/apps/client/src/features/modals/upload-modal/UploadModal.module.scss index fdd7d5778..c35c1a31d 100644 --- a/apps/client/src/features/modals/upload-modal/UploadModal.module.scss +++ b/apps/client/src/features/modals/upload-modal/UploadModal.module.scss @@ -5,79 +5,31 @@ .uploadBody { display: flex; flex-direction: column; - gap: 16px; + gap: 1rem; } .uploadArea { + margin: 0 auto; width: 100%; - min-height: 200px; - border: 2px dashed $gray-50; + max-width: 550px; + + min-height: 150px; + border: 2px dashed $gray-200; border-radius: 3px; display: grid; place-content: center; transition-property: background-color; transition-duration: $transition-time-action; + font-size: calc(1rem - 1px); &:hover { border: 2px solid $blue-500; background-color: $blue-50; cursor: pointer; } + &.comment { - color: gray; - } -} - -.uploadedItem { - background-color: $gray-50; - padding: 8px; - display: grid; - grid-template-areas: - "icon title close" - "icon info ." - "progress progress progress"; - grid-template-columns: auto 1fr auto; - column-gap: 16px; - border-radius: 3px; - - .icon { - align-self: center; - grid-area: icon; - font-size: 32px; - color: $gray-700; - } - - .fileTitle { - grid-area: title; - font-size: 14px; - color: $gray-1350; - } - - .fileInfo { - grid-area: info; - font-size: 12px; - color: $gray-1100; - } - - .fileProgress { - grid-area: progress; - } - - .cancelUpload { - grid-area: close; - cursor: pointer; - } - - &.error { - .icon { - color: $error-red; - } - } - - &.success { - .icon { - color: $green-500; - } + color: $modal-note-color; } } @@ -89,3 +41,9 @@ .pad { margin: 8px; } + +.twoColumn { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1rem; +} diff --git a/apps/client/src/features/modals/upload-modal/UploadModal.tsx b/apps/client/src/features/modals/upload-modal/UploadModal.tsx index 0477ff85b..d2ec1835a 100644 --- a/apps/client/src/features/modals/upload-modal/UploadModal.tsx +++ b/apps/client/src/features/modals/upload-modal/UploadModal.tsx @@ -1,7 +1,6 @@ -import { ChangeEvent, useCallback, useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Button, - Input, Modal, ModalBody, ModalCloseButton, @@ -9,433 +8,35 @@ import { ModalFooter, ModalHeader, ModalOverlay, - Progress, - Switch, } from '@chakra-ui/react'; -import { IoClose } from '@react-icons/all-files/io5/IoClose'; -import { IoDocumentTextOutline } from '@react-icons/all-files/io5/IoDocumentTextOutline'; -import { IoWarningOutline } from '@react-icons/all-files/io5/IoWarningOutline'; import { useQueryClient } from '@tanstack/react-query'; import { OntimeRundown } from 'ontime-types'; import { RUNDOWN_TABLE } from '../../../common/api/apiConstants'; import { postPreviewExcel, uploadData } from '../../../common/api/ontimeApi'; -import PreviewProjectData from '../../../common/components/import-preview/PreviewProjectData'; -import PreviewRundown from '../../../common/components/import-preview/PreviewRundown'; -import PreviewUserField from '../../../common/components/import-preview/PreviewUserFields'; import { projectDataPlaceholder } from '../../../common/models/ProjectData'; import { userFieldsPlaceholder } from '../../../common/models/UserFields'; -import { useEmitLog } from '../../../common/stores/logger'; -import ModalSplitInput from '../ModalSplitInput'; +import { cx } from '../../../common/utils/styleUtils'; -import { validateFile } from './utils'; +import ExcelFileOptions from './upload-options/ExcelFileOptions'; +import OntimeFileOptions from './upload-options/OntimeFileOptions'; +import UploadStepTracker from './upload-step/UploadStep'; +import ReviewFile from './ReviewExcel'; +import UploadFile from './UploadFile'; +import { useUploadModalContextStore } from './uploadModalContext'; +import { defaultExcelImportMap, ExcelImportMapKeys, isExcelFile, isOntimeFile } from './uploadUtils'; import style from './UploadModal.module.scss'; -const rundownDemo: OntimeRundown = [ - { - title: 'Albania', - subtitle: 'Sekret', - presenter: 'Ronela Hajati', - note: 'SF1.01', - endAction: 'none', - timerType: 'count-down', - timeStart: 36000000, - timeEnd: 37200000, - duration: 1200000, - isPublic: true, - skip: false, - colour: '', - user0: '', - user1: '', - user2: '', - user3: '', - user4: '', - user5: '', - user6: '', - user7: '', - user8: '', - user9: '', - type: 'event', - revision: 0, - id: '32d31', - cue: '1', - }, - { - title: 'Latvia', - subtitle: 'Eat Your Salad', - presenter: 'Citi Zeni', - note: 'SF1.02', - endAction: 'none', - timerType: 'count-down', - timeStart: 37500000, - timeEnd: 38700000, - duration: 1200000, - isPublic: true, - skip: false, - colour: '', - user0: '', - user1: '', - user2: '', - user3: '', - user4: '', - user5: '', - user6: '', - user7: '', - user8: '', - user9: '', - type: 'event', - revision: 0, - id: '21cd2', - cue: '2', - }, - { - title: 'Lithuania', - subtitle: 'Sentimentai', - presenter: 'Monika Liu', - note: 'SF1.03', - endAction: 'none', - timerType: 'count-down', - timeStart: 39000000, - timeEnd: 40200000, - duration: 1200000, - isPublic: true, - skip: false, - colour: '', - user0: '', - user1: '', - user2: '', - user3: '', - user4: '', - user5: '', - user6: '', - user7: '', - user8: '', - user9: '', - type: 'event', - revision: 0, - id: '0b371', - cue: '3', - }, - { - title: 'Switzerland', - subtitle: 'Boys Do Cry', - presenter: 'Marius Bear', - note: 'SF1.04', - endAction: 'none', - timerType: 'count-down', - timeStart: 40500000, - timeEnd: 41700000, - duration: 1200000, - isPublic: true, - skip: false, - colour: '', - user0: '', - user1: '', - user2: '', - user3: '', - user4: '', - user5: '', - user6: '', - user7: '', - user8: '', - user9: '', - type: 'event', - revision: 0, - id: '3cd28', - cue: '4', - }, - { - title: 'Slovenia', - subtitle: 'Disko', - presenter: 'LPS', - note: 'SF1.05', - endAction: 'none', - timerType: 'count-down', - timeStart: 42000000, - timeEnd: 43200000, - duration: 1200000, - isPublic: true, - skip: false, - colour: '', - user0: '', - user1: '', - user2: '', - user3: '', - user4: '', - user5: '', - user6: '', - user7: '', - user8: '', - user9: '', - type: 'event', - revision: 0, - id: 'e457f', - cue: '5', - }, - { - title: 'Lunch break', - type: 'block', - id: '01e85', - }, - { - title: 'Ukraine', - subtitle: 'Stefania', - presenter: 'Kalush Orchestra', - note: 'SF1.06', - endAction: 'none', - timerType: 'count-down', - timeStart: 47100000, - timeEnd: 48300000, - duration: 1200000, - isPublic: true, - skip: false, - colour: '', - user0: '', - user1: '', - user2: '', - user3: '', - user4: '', - user5: '', - user6: '', - user7: '', - user8: '', - user9: '', - type: 'event', - revision: 0, - id: '1c420', - cue: '6', - }, - { - title: 'Bulgaria', - subtitle: 'Intention', - presenter: 'Intelligent Music Project', - note: 'SF1.07', - endAction: 'none', - timerType: 'count-down', - timeStart: 48600000, - timeEnd: 49800000, - duration: 1200000, - isPublic: true, - skip: false, - colour: '', - user0: '', - user1: '', - user2: '', - user3: '', - user4: '', - user5: '', - user6: '', - user7: '', - user8: '', - user9: '', - type: 'event', - revision: 0, - id: 'b7737', - cue: '7', - }, - { - title: 'Netherlands', - subtitle: 'De Diepte', - presenter: 'S10', - note: 'SF1.08', - endAction: 'none', - timerType: 'count-down', - timeStart: 50100000, - timeEnd: 51300000, - duration: 1200000, - isPublic: true, - skip: false, - colour: '', - user0: '', - user1: '', - user2: '', - user3: '', - user4: '', - user5: '', - user6: '', - user7: '', - user8: '', - user9: '', - type: 'event', - revision: 0, - id: 'd3a80', - cue: '8', - }, - { - title: 'Moldova', - subtitle: 'Trenuletul', - presenter: 'Zdob si Zdub', - note: 'SF1.09', - endAction: 'none', - timerType: 'count-down', - timeStart: 51600000, - timeEnd: 52800000, - duration: 1200000, - isPublic: true, - skip: false, - colour: '', - user0: '', - user1: '', - user2: '', - user3: '', - user4: '', - user5: '', - user6: '', - user7: '', - user8: '', - user9: '', - type: 'event', - revision: 0, - id: '8276c', - cue: '9', - }, - { - title: 'Portugal', - subtitle: 'Saudade Saudade', - presenter: 'Maro', - note: 'SF1.10', - endAction: 'none', - timerType: 'count-down', - timeStart: 53100000, - timeEnd: 54300000, - duration: 1200000, - isPublic: true, - skip: false, - colour: '', - user0: '', - user1: '', - user2: '', - user3: '', - user4: '', - user5: '', - user6: '', - user7: '', - user8: '', - user9: '', - type: 'event', - revision: 0, - id: '2340b', - cue: '10', - }, - { - title: 'Afternoon break', - type: 'block', - id: 'cb90b', - }, - { - title: 'Croatia', - subtitle: 'Guilty Pleasure', - presenter: 'Mia Dimsic', - note: 'SF1.11', - endAction: 'none', - timerType: 'count-down', - timeStart: 56100000, - timeEnd: 57300000, - duration: 1200000, - isPublic: true, - skip: false, - colour: '', - user0: '', - user1: '', - user2: '', - user3: '', - user4: '', - user5: '', - user6: '', - user7: '', - user8: '', - user9: '', - type: 'event', - revision: 0, - id: '503c4', - cue: '11', - }, - { - title: 'Denmark', - subtitle: 'The Show', - presenter: 'Reddi', - note: 'SF1.12', - endAction: 'none', - timerType: 'count-down', - timeStart: 57600000, - timeEnd: 58800000, - duration: 1200000, - isPublic: true, - skip: false, - colour: '', - user0: '', - user1: '', - user2: '', - user3: '', - user4: '', - user5: '', - user6: '', - user7: '', - user8: '', - user9: '', - type: 'event', - revision: 0, - id: '5e965', - cue: '12', - }, - { - title: 'Austria', - subtitle: 'Halo', - presenter: 'LUM!X & Pia Maria', - note: 'SF1.13', - endAction: 'none', - timerType: 'count-down', - timeStart: 59100000, - timeEnd: 60300000, - duration: 1200000, - isPublic: true, - skip: false, - colour: '', - user0: '', - user1: '', - user2: '', - user3: '', - user4: '', - user5: '', - user6: '', - user7: '', - user8: '', - user9: '', - type: 'event', - revision: 0, - id: 'bab4a', - cue: '13', - }, - { - title: 'Greece', - subtitle: 'Die Together', - presenter: 'Amanda Tenfjord', - note: 'SF1.14', - endAction: 'none', - timerType: 'count-down', - timeStart: 60600000, - timeEnd: 61800000, - duration: 1200000, - isPublic: true, - skip: false, - colour: '', - user0: '', - user1: '', - user2: '', - user3: '', - user4: '', - user5: '', - user6: '', - user7: '', - user8: '', - user9: '', - type: 'event', - revision: 0, - id: 'd3eb1', - cue: '14', - }, -]; +export type UploadStep = 'upload' | 'review'; + +export interface OntimeInputOptions { + onlyImportRundown?: boolean; +} + +export type ExcelInputOptions = { + [K in ExcelImportMapKeys]: string; +}; interface UploadModalProps { onClose: () => void; @@ -444,73 +45,106 @@ interface UploadModalProps { export default function UploadModal({ onClose, isOpen }: UploadModalProps) { const queryClient = useQueryClient(); - const { emitError } = useEmitLog(); - const [errors, setErrors] = useState(); - const [isSubmitting, setSubmitting] = useState(false); - const [file, setFile] = useState(null); - const [progress, setProgress] = useState(0); - const overrideOptionRef = useRef(null); - const fileInputRef = useRef(null); - const [success, setSuccess] = useState(false); - const handleFile = useCallback((event: ChangeEvent) => { - const fileUploaded = event?.target?.files?.[0]; - if (!fileUploaded) return; + const { file, setProgress, clear } = useUploadModalContextStore(); - const validate = validateFile(fileUploaded); - setErrors(validate.errors?.[0]); + const [uploadStep, setUploadStep] = useState('upload'); + const [submitting, setSubmitting] = useState(false); + const [rundown, setRundown] = useState([]); + const [userFields, setUserFields] = useState(userFieldsPlaceholder); + const [project, setProject] = useState(projectDataPlaceholder); - if (validate.isValid) { - setFile(fileUploaded); - } else { - setFile(null); - } - }, []); + const [errors, setErrors] = useState(''); - const handleSubmit = useCallback(async () => { - setSubmitting(true); - console.log(file); + const ontimeFileOptions = useRef>({}); + const excelFileOptions = useRef>(defaultExcelImportMap); + + useEffect(() => { + clear(); + setUploadStep('upload'); + setSubmitting(false); + setRundown([]); + setUserFields(userFieldsPlaceholder); + setProject(projectDataPlaceholder); + setErrors(''); + }, [clear, isOpen]); + + const handleParse = async () => { if (file) { - // not the most robust, but ok for now - if (file.name.endsWith('.xlsx')) { - console.log('pushing to preview'); - const r = await postPreviewExcel(file, setProgress, {}); - console.log('got from preview', r); - } else { - try { - const options = { - onlyRundown: overrideOptionRef.current?.checked || false, - }; - await uploadData(file, setProgress, options); - } catch (error) { - emitError(`Failed uploading file: ${error}`); - } finally { + setSubmitting(true); + try { + if (isOntimeFile(file)) { + await handleOntimeFile(file); await queryClient.invalidateQueries(RUNDOWN_TABLE); - setSuccess(true); + } else if (isExcelFile(file)) { + await handleExcelFile(file); } + } catch (error) { + setErrors(`Failed uploading file: ${error}`); + } finally { + setSubmitting(false); } } - setSubmitting(false); - }, [emitError, file, queryClient]); - const handleClick = () => { - fileInputRef.current?.click(); - }; + async function handleExcelFile(file: File) { + const options = excelFileOptions.current; + // TODO: option type should be central, to also be used by backend + const response = await postPreviewExcel(file, setProgress, options); + if (response.status === 200) { + setRundown(response.data.rundown); + setUserFields(response.data.userFields); + setProject(response.data.project); + setUploadStep('review'); + } + } - const clearFile = () => { - setFile(null); + async function handleOntimeFile(file: File) { + const options = { + onlyRundown: Boolean(ontimeFileOptions.current.onlyImportRundown), + }; + await uploadData(file, setProgress, options); + } }; const handleClose = () => { - clearFile(); - setSuccess(false); - setErrors(undefined); - setProgress(0); + clear(); + setRundown([]); + setUserFields(userFieldsPlaceholder); + setProject(projectDataPlaceholder); onClose(); }; - const disableSubmit = !file || isSubmitting; + const handleFinalise = async () => { + if (file) { + setSubmitting(true); + try { + const options = { + //onlyRundown: overrideOptionRef.current?.checked || false, + }; + await uploadData(file, setProgress, options); + handleClose(); + } catch (error) { + console.error(error); + } finally { + await queryClient.invalidateQueries(RUNDOWN_TABLE); + setSubmitting(false); + } + } + }; + const isUpload = uploadStep === 'upload'; + const isExcel = isExcelFile(file); + const isOntime = isOntimeFile(file); + + const handleGoBack = isUpload ? undefined : () => setUploadStep('upload'); + const handleSubmit = isUpload ? handleParse : handleFinalise; + const disableSubmit = isUpload && !file; + const disableGoBack = isUpload; + const submitText = isUpload ? 'Upload' : 'Finish'; + + const modalClasses = cx([style.modalWidthOverride, isExcel ? style.doExtend : null]); + + console.log('debug', isExcel, modalClasses); return ( File import - -
- Click to upload Ontime project file -
- {file && ( -
- - - {file.name} - {`${(file.size / 1024).toFixed(2)}kb - ${file.type}`} - -
+ {isExcel && } + {uploadStep === 'upload' ? ( + <> + + {errors &&
{errors}
} + {isOntime && } + {isExcel && } + + ) : ( + )} - {errors && ( -
- - {errors} - Please try again - -
- )} -
- Import options - - - -
- - -
-
diff --git a/apps/client/src/features/modals/upload-modal/upload-entry/UploadEntry.module.scss b/apps/client/src/features/modals/upload-modal/upload-entry/UploadEntry.module.scss new file mode 100644 index 000000000..8adb54112 --- /dev/null +++ b/apps/client/src/features/modals/upload-modal/upload-entry/UploadEntry.module.scss @@ -0,0 +1,59 @@ +@use '../../../../theme/ontimeColours' as *; +@use '../../../../theme/v2Styles' as *; + +.uploadedItem { + margin: 0 auto; + width: 100%; + max-width: 550px; + + border: 1px solid $gray-200; + padding: 0.5rem; + display: grid; + grid-template-areas: + "icon title close" + "icon info ." + "progress progress progress"; + grid-template-columns: auto 1fr auto; + column-gap: 1rem; + border-radius: 3px; + + .icon { + align-self: center; + grid-area: icon; + font-size: 2rem; + color: $gray-700; + } + + .fileTitle { + grid-area: title; + font-size: calc(1rem - 2px); + color: $ui-black; + } + + .fileInfo { + grid-area: info; + font-size: calc(1rem - 4px); + color: $gray-1100; + } + + .fileProgress { + grid-area: progress; + } + + .cancelUpload { + grid-area: close; + cursor: pointer; + } + + &.error { + .icon { + color: $error-red; + } + } + + &.success { + .icon { + color: $green-500; + } + } +} diff --git a/apps/client/src/features/modals/upload-modal/upload-entry/UploadEntry.tsx b/apps/client/src/features/modals/upload-modal/upload-entry/UploadEntry.tsx new file mode 100644 index 000000000..fa8da716c --- /dev/null +++ b/apps/client/src/features/modals/upload-modal/upload-entry/UploadEntry.tsx @@ -0,0 +1,53 @@ +import { Progress } from '@chakra-ui/react'; +import { IoClose } from '@react-icons/all-files/io5/IoClose'; +import { IoDocumentTextOutline } from '@react-icons/all-files/io5/IoDocumentTextOutline'; +import { IoWarningOutline } from '@react-icons/all-files/io5/IoWarningOutline'; + +import { isExcelFile, isOntimeFile } from '../uploadUtils'; + +import style from './UploadEntry.module.scss'; + +interface UploadEntryProps { + file: File | null; + errors?: string; + progress: number; + success: boolean; + handleClear: () => void; +} + +export default function UploadEntry(props: UploadEntryProps) { + const { file, errors, progress, success, handleClear } = props; + + if (errors) { + return ( +
+ + {errors} + Please try again + +
+ ); + } + + if (file) { + const fileSize = `${(file.size / 1024).toFixed(2)}kb`; + let fileType = ''; + if (isOntimeFile(file)) { + fileType = 'Ontime Project File'; + } else if (isExcelFile(file)) { + fileType = 'Excel Rundown'; + } + + return ( +
+ + + {file.name} + {`${fileSize} - ${fileType}`} + +
+ ); + } + + return null; +} diff --git a/apps/client/src/features/modals/upload-modal/upload-options/ExcelFileOptions.tsx b/apps/client/src/features/modals/upload-modal/upload-options/ExcelFileOptions.tsx new file mode 100644 index 000000000..ba090bcfa --- /dev/null +++ b/apps/client/src/features/modals/upload-modal/upload-options/ExcelFileOptions.tsx @@ -0,0 +1,78 @@ +import { MutableRefObject } from 'react'; +import { Input } from '@chakra-ui/react'; + +import ModalSplitInput from '../../ModalSplitInput'; + +import ImportMapTable, { type TableEntry } from './ImportMapTable'; +import { ExcelInputOptions } from '../UploadModal'; + +import style from '../UploadModal.module.scss'; + +interface ExcelFileOptionsProps { + optionsRef: MutableRefObject; +} + +export default function ExcelFileOptions(props: ExcelFileOptionsProps) { + const { optionsRef } = props; + + const updateRef = (field: T, value: ExcelInputOptions[T]) => { + // avoid unnecessary changes + if (optionsRef.current[field] !== value) { + optionsRef.current = { ...optionsRef.current, [field]: value }; + } + }; + + const worksheet: TableEntry[] = [{ label: 'Worksheet', title: 'worksheet', value: optionsRef.current.worksheet }]; + + const timings: TableEntry[] = [ + { label: 'Start time', title: 'timeStart', value: optionsRef.current.timeStart }, + { label: 'End Time', title: 'timeEnd', value: optionsRef.current.timeEnd }, + { label: 'Duration', title: 'duration', value: optionsRef.current.duration }, + ]; + + const titles: TableEntry[] = [ + { label: 'Cue', title: 'cue', value: optionsRef.current.cue }, + { label: 'Colour', title: 'colour', value: optionsRef.current.colour }, + { label: 'Title', title: 'title', value: optionsRef.current.title }, + { label: 'Presenter', title: 'presenter', value: optionsRef.current.presenter }, + { label: 'Subtitle', title: 'subtitle', value: optionsRef.current.subtitle }, + { label: 'Note', title: 'note', value: optionsRef.current.note }, + ]; + + const options: TableEntry[] = [ + { label: 'Is Public', title: 'isPublic', value: optionsRef.current.isPublic }, + { label: 'Timer Type', title: 'timerType', value: optionsRef.current.timerType }, + { label: 'End Action', title: 'endAction', value: optionsRef.current.endAction }, + ]; + + const userFields: TableEntry[] = [ + { label: 'User 0', title: 'user0', value: optionsRef.current.user0 }, + { label: 'User 1', title: 'user1', value: optionsRef.current.user1 }, + { label: 'User 2', title: 'user2', value: optionsRef.current.user2 }, + { label: 'User 3', title: 'user3', value: optionsRef.current.user3 }, + { label: 'User 4', title: 'user4', value: optionsRef.current.user4 }, + { label: 'User 5', title: 'user5', value: optionsRef.current.user5 }, + { label: 'User 6', title: 'user6', value: optionsRef.current.user6 }, + { label: 'User 7', title: 'user7', value: optionsRef.current.user7 }, + { label: 'User 8', title: 'user8', value: optionsRef.current.user8 }, + { label: 'User 9', title: 'user9', value: optionsRef.current.user9 }, + ]; + + return ( +
+
+ +
+ +
+ + +
+ +
+ + +
+
+ ); +} diff --git a/apps/client/src/features/modals/upload-modal/upload-options/ImportMapTable.module.scss b/apps/client/src/features/modals/upload-modal/upload-options/ImportMapTable.module.scss new file mode 100644 index 000000000..de49d00d5 --- /dev/null +++ b/apps/client/src/features/modals/upload-modal/upload-options/ImportMapTable.module.scss @@ -0,0 +1,22 @@ +@use '../../../../theme/v2Styles' as *; +@use '../../../../theme/ontimeColours' as *; + +.importTable { + margin: 0.5rem; + font-size: $inner-section-text-size; + height: fit-content; + + thead { + color: $gray-500; + text-transform: uppercase; + } + + tr:hover { + background-color: $gray-50; + } +} + +.label { + display: inline-block; + width: 100%; +} diff --git a/apps/client/src/features/modals/upload-modal/upload-options/ImportMapTable.tsx b/apps/client/src/features/modals/upload-modal/upload-options/ImportMapTable.tsx new file mode 100644 index 000000000..45e9325c6 --- /dev/null +++ b/apps/client/src/features/modals/upload-modal/upload-options/ImportMapTable.tsx @@ -0,0 +1,50 @@ +import { Input } from '@chakra-ui/react'; + +import { ExcelInputOptions } from '../UploadModal'; + +import style from './ImportMapTable.module.scss'; + +// TODO: make this generic +export type TableEntry = { label: string; title: keyof ExcelInputOptions; value: string }; + +interface ImportMapTableProps { + title: string; + fields: TableEntry[]; + handleOnChange: (field: keyof ExcelInputOptions, value: string) => void; +} + +export default function ImportMapTable(props: ImportMapTableProps) { + const { title, fields, handleOnChange } = props; + + return ( + + {title} + + {fields.map((field) => { + return ( + + + + + ); + })} + +
+ + + { + handleOnChange(field.title, event.target.value); + }} + /> +
+ ); +} diff --git a/apps/client/src/features/modals/upload-modal/upload-options/OntimeFileOptions.tsx b/apps/client/src/features/modals/upload-modal/upload-options/OntimeFileOptions.tsx new file mode 100644 index 000000000..21e37c35c --- /dev/null +++ b/apps/client/src/features/modals/upload-modal/upload-options/OntimeFileOptions.tsx @@ -0,0 +1,37 @@ +import { MutableRefObject } from 'react'; +import { Switch } from '@chakra-ui/react'; + +import ModalSplitInput from '../../ModalSplitInput'; +import { OntimeInputOptions } from '../UploadModal'; + +import style from '../UploadModal.module.scss'; + +interface OntimeFileOptionsProps { + optionsRef: MutableRefObject; +} + +export default function OntimeFileOptions(props: OntimeFileOptionsProps) { + const { optionsRef } = props; + + const updateRef = (field: T, value: OntimeInputOptions[T]) => { + optionsRef.current = { ...optionsRef.current, [field]: value }; + }; + + return ( +
+ Import options + + { + updateRef('onlyImportRundown', e.target.checked); + }} + /> + +
+ ); +} diff --git a/apps/client/src/features/modals/upload-modal/upload-step/UploadStep.module.scss b/apps/client/src/features/modals/upload-modal/upload-step/UploadStep.module.scss new file mode 100644 index 000000000..f2f59fc0f --- /dev/null +++ b/apps/client/src/features/modals/upload-modal/upload-step/UploadStep.module.scss @@ -0,0 +1,40 @@ +@use '../../../../theme/ontimeColours' as *; + +@mixin row { + display: flex; + align-items: center; + gap: 0.25rem; + padding: 0 0.25rem; +} + +.stepRow { + display: flex; + gap: 2rem; + align-items: center; + margin: 0 auto; + font-size: 1rem; +} + +.idle { + @include row; + color: $blue-500; +} + +.inactive { + @include row; + color: $gray-700; +} + +.active { + @include row; + color: $blue-700; +} + + +.inactiveIcon { + color: $gray-700; +} + +.activeIcon { + color: $blue-500; +} diff --git a/apps/client/src/features/modals/upload-modal/upload-step/UploadStep.tsx b/apps/client/src/features/modals/upload-modal/upload-step/UploadStep.tsx new file mode 100644 index 000000000..5c15c164f --- /dev/null +++ b/apps/client/src/features/modals/upload-modal/upload-step/UploadStep.tsx @@ -0,0 +1,26 @@ +import { IoCheckmarkCircle } from '@react-icons/all-files/io5/IoCheckmarkCircle'; +import { IoChevronForward } from '@react-icons/all-files/io5/IoChevronForward'; +import { IoEllipseOutline } from '@react-icons/all-files/io5/IoEllipseOutline'; + +import type { UploadStep } from '../UploadModal'; + +import style from './UploadStep.module.scss'; + +export default function UploadStepTracker({ uploadStep }: { uploadStep: UploadStep }) { + const isUpload = uploadStep === 'upload'; + const isReview = uploadStep === 'review'; + + return ( +
+
+ + Upload +
+ +
+ {isReview ? : } + Review +
+
+ ); +} diff --git a/apps/client/src/features/modals/upload-modal/uploadModalContext.tsx b/apps/client/src/features/modals/upload-modal/uploadModalContext.tsx new file mode 100644 index 000000000..e17bc0869 --- /dev/null +++ b/apps/client/src/features/modals/upload-modal/uploadModalContext.tsx @@ -0,0 +1,21 @@ +import { create } from 'zustand'; + +type UploadModalContext = { + file: File | null; + setFile: (file: File | null) => void; + + progress: number; + setProgress: (progress: number) => void; + + clear: () => void; +}; + +export const useUploadModalContextStore = create((set) => ({ + file: null, + setFile: (file: File | null) => set({ file }), + + progress: 0, + setProgress: (progress: number) => set({ progress }), + + clear: () => set({ file: null, progress: 0 }), +})); diff --git a/apps/client/src/features/modals/upload-modal/uploadUtils.ts b/apps/client/src/features/modals/upload-modal/uploadUtils.ts new file mode 100644 index 000000000..e86f6ff03 --- /dev/null +++ b/apps/client/src/features/modals/upload-modal/uploadUtils.ts @@ -0,0 +1,70 @@ +type ValidationStatus = { + errors: string[]; + isValid: boolean; +}; + +export function validateFile(file: File): ValidationStatus { + const status: ValidationStatus = { errors: [], isValid: true }; + if (!file) { + status.errors.push('No file to upload'); + status.isValid = false; + } + + // Limit file size to 1MB + if (file.size > 1000000) { + status.errors.push('File size limit (1MB) exceeded'); + status.isValid = false; + } + + // Check file extension + if (!file.name.endsWith('.xlsx') && !file.name.endsWith('.json')) { + status.errors.push('Unhandled file type'); + status.isValid = false; + } + return status; +} + +export type MaybeFile = null | 'ontime' | 'excel'; + +export function isExcelFile(file: File | null) { + return file?.name.endsWith('.xlsx'); +} + +export function isOntimeFile(file: File | null) { + return file?.name.endsWith('.json'); +} + +export type ExcelImportMapKeys = keyof typeof defaultExcelImportMap; + +export const defaultExcelImportMap = { + worksheet: 'ontime', + projectName: 'project name', + projectDescription: 'project description', + publicUrl: 'public url', + publicInfo: 'public info', + backstageUrl: 'backstage url', + backstageInfo: 'backstage info', + timeStart: 'start', + timeEnd: 'end', + duration: 'duration', + cue: 'cue', + title: 'title', + presenter: 'presenter', + subtitle: 'subtitle', + isPublic: 'public', + skip: 'skip', + note: 'note', + colour: 'colour', + endAction: 'end action', + timerType: 'timer type', + user0: 'user0', + user1: 'user1', + user2: 'user2', + user3: 'user3', + user4: 'user4', + user5: 'user5', + user6: 'user6', + user7: 'user7', + user8: 'user8', + user9: 'user9', +}; diff --git a/apps/client/src/features/modals/upload-modal/utils.ts b/apps/client/src/features/modals/upload-modal/utils.ts deleted file mode 100644 index 940448571..000000000 --- a/apps/client/src/features/modals/upload-modal/utils.ts +++ /dev/null @@ -1,25 +0,0 @@ -type ValidationStatus = { - errors: string[]; - isValid: boolean; -}; - -export function validateFile(file: File): ValidationStatus { - const status: ValidationStatus = { errors: [], isValid: true }; - if (!file) { - status.errors.push('No file to upload'); - status.isValid = false; - } - - // Limit file size to 1MB - if (file.size > 1000000) { - status.errors.push('File size limit (1MB) exceeded'); - status.isValid = false; - } - - // Check file extension - if (!file.name.endsWith('.xlsx') && !file.name.endsWith('.json')) { - status.errors.push('Unhandled file type'); - status.isValid = false; - } - return status; -} diff --git a/apps/client/src/theme/OntimeProgress.ts b/apps/client/src/theme/OntimeProgress.ts new file mode 100644 index 000000000..cb5c7bf2e --- /dev/null +++ b/apps/client/src/theme/OntimeProgress.ts @@ -0,0 +1,8 @@ +export const ontimeProgressGray = { + track: { + background: '#f6f6f6', // $gray-500 + }, + filledTrack: { + background: '#578AF4', // $blue-500 + }, +}; diff --git a/apps/client/src/theme/ontimeModal.ts b/apps/client/src/theme/ontimeModal.ts index bba0429cd..6abae3dd5 100644 --- a/apps/client/src/theme/ontimeModal.ts +++ b/apps/client/src/theme/ontimeModal.ts @@ -2,8 +2,8 @@ export const ontimeModal = { header: { fontWeight: 400, letterSpacing: '0.3px', - padding: '16px 24px', - fontSize: '20px', + padding: '1rem 1.5rem', + fontSize: '1.25rem', color: '#202020', // $gray-50 }, dialog: { @@ -20,17 +20,29 @@ export const ontimeModal = { color: '#202020', // $gray-50 }, footer: { - padding: '8px', + padding: '0.5rem', }, }; export const ontimeSmallModal = { ...ontimeModal, body: { - padding: '16px', - fontSize: '14px', + padding: '1rem', + fontSize: 'calc(1rem - 2px)', }, dialog: { minHeight: 'min(200px, 10vh)', }, }; + +export const ontimeUploadModal = { + ...ontimeSmallModal, + body: { + padding: '1rem', + fontSize: 'calc(1rem - 2px)', + }, + dialog: { + minHeight: 'min(200px, 10vh)', + maxWidth: 'min(800px, 80vh)', + }, +}; diff --git a/apps/client/src/theme/theme.ts b/apps/client/src/theme/theme.ts index d1b89a58b..2378a270e 100644 --- a/apps/client/src/theme/theme.ts +++ b/apps/client/src/theme/theme.ts @@ -13,7 +13,8 @@ import { import { ontimeCheckboxOnDark } from './ontimeCheckbox'; import { ontimeEditable } from './ontimeEditable'; import { ontimeMenuOnDark } from './ontimeMenu'; -import { ontimeModal, ontimeSmallModal } from './ontimeModal'; +import { ontimeModal, ontimeSmallModal, ontimeUploadModal } from './ontimeModal'; +import { ontimeProgressGray } from './OntimeProgress'; import { ontimeBlockRadio } from './ontimeRadio'; import { ontimeSelect } from './ontimeSelect'; import { lightSwitch, ontimeSwitch } from './ontimeSwitch'; @@ -79,6 +80,12 @@ const theme = extendTheme({ variants: { ontime: { ...ontimeModal }, 'ontime-small': { ...ontimeSmallModal }, + 'ontime-upload': { ...ontimeUploadModal }, + }, + }, + Progress: { + variants: { + 'ontime-on-light': { ...ontimeProgressGray }, }, }, Radio: { diff --git a/apps/electron/package.json b/apps/electron/package.json index 6ea2ac7dd..799a85a3b 100644 --- a/apps/electron/package.json +++ b/apps/electron/package.json @@ -1,6 +1,6 @@ { "name": "ontime", - "version": "2.7.2", + "version": "2.8.1-rc-table", "author": "Carlos Valente", "description": "Time keeping for live events", "repository": "https://github.com/cpvalente/ontime", diff --git a/apps/server/package.json b/apps/server/package.json index c43f35e5e..cb3a191c4 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -2,7 +2,7 @@ "name": "ontime-server", "type": "module", "main": "src/index.ts", - "version": "2.7.2", + "version": "2.8.1-rc-table", "exports": "./src/index.js", "dependencies": { "body-parser": "^1.20.0", diff --git a/apps/server/src/controllers/ontimeController.ts b/apps/server/src/controllers/ontimeController.ts index f736b13aa..7e2ccc50b 100644 --- a/apps/server/src/controllers/ontimeController.ts +++ b/apps/server/src/controllers/ontimeController.ts @@ -344,6 +344,8 @@ export async function previewExcel(req, res) { return; } const options = req.query; + const options2 = JSON.parse(req.body.options); + console.log(options2); const file = req.file.path; try { const data = await justUploadAndParse(file, req, res, options); diff --git a/package.json b/package.json index 28fb90ca2..bf188e899 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ontime", - "version": "2.7.2", + "version": "2.8.1-rc-table", "description": "Time keeping for live events", "keywords": [ "lighdev",