diff --git a/apps/client/src/common/api/apiUtils.ts b/apps/client/src/common/api/apiUtils.ts index 29f919212..815454ab7 100644 --- a/apps/client/src/common/api/apiUtils.ts +++ b/apps/client/src/common/api/apiUtils.ts @@ -42,12 +42,5 @@ export function logAxiosError(prepend: string, error: unknown) { * Utility function invalidates react-query caches */ export async function invalidateAllCaches() { - await ontimeQueryClient.invalidateQueries({ queryKey: ['project'] }); - await ontimeQueryClient.invalidateQueries({ queryKey: ['aliases'] }); - await ontimeQueryClient.invalidateQueries({ queryKey: ['userFields'] }); - await ontimeQueryClient.invalidateQueries({ queryKey: ['rundown'] }); - await ontimeQueryClient.invalidateQueries({ queryKey: ['appinfo'] }); - await ontimeQueryClient.invalidateQueries({ queryKey: ['oscSettings'] }); - await ontimeQueryClient.invalidateQueries({ queryKey: ['appSettings'] }); - await ontimeQueryClient.invalidateQueries({ queryKey: ['viewSettings'] }); + await ontimeQueryClient.invalidateQueries(); } diff --git a/apps/client/src/common/api/ontimeApi.ts b/apps/client/src/common/api/ontimeApi.ts index d741de831..ce18f3e03 100644 --- a/apps/client/src/common/api/ontimeApi.ts +++ b/apps/client/src/common/api/ontimeApi.ts @@ -13,6 +13,7 @@ import { UserFields, ViewSettings, } from 'ontime-types'; +import { MessageResponse } from 'ontime-types'; import { ExcelImportMap } from 'ontime-utils'; import { apiRepoLatest } from '../../externals'; @@ -257,6 +258,16 @@ export async function getProjects(): Promise { return res.data; } +/** + * @description HTTP request to load a project file + */ +export async function loadProject(filename: string): Promise { + const res = await axios.post(`${ontimeURL}/load-project`, { + filename, + }); + return res.data; +} + /** * @description STEP 1 */ @@ -329,3 +340,49 @@ export const postPushSheet = async (id: string, options: ExcelImportMap) => { const response = await axios.post(`${ontimeURL}/sheet-push`, { id, options }); return response.data.data; }; + +/** + * @description HTTP request to rename a project file + */ +export async function renameProject(filename: string, newFilename: string): Promise { + const url = `${ontimeURL}/project/${filename}/rename`; + const decodedUrl = decodeURIComponent(url); + const res = await axios.put(decodedUrl, { + newFilename, + }); + return res.data; +} + +/** + * @description HTTP request to duplicate a project file + */ +export async function duplicateProject(filename: string, newFilename: string): Promise { + const url = `${ontimeURL}/project/${filename}/duplicate`; + const decodedUrl = decodeURIComponent(url); + const res = await axios.post(decodedUrl, { + newFilename, + }); + return res.data; +} + +/** + * @description HTTP request to delete a project file + */ +export async function deleteProject(filename: string): Promise { + const url = `${ontimeURL}/project/${filename}`; + const decodedUrl = decodeURIComponent(url); + const res = await axios.delete(decodedUrl); + return res.data; +} + +/** + * @description HTTP request to create a project file + */ +export async function createProject(filename: string): Promise { + const url = `${ontimeURL}/project`; + const decodedUrl = decodeURIComponent(url); + const res = await axios.post(decodedUrl, { + filename, + }); + return res.data; +} diff --git a/apps/client/src/common/hooks-query/useProjectList.ts b/apps/client/src/common/hooks-query/useProjectList.ts index 9527f19dc..a4f587185 100644 --- a/apps/client/src/common/hooks-query/useProjectList.ts +++ b/apps/client/src/common/hooks-query/useProjectList.ts @@ -11,7 +11,7 @@ const placeholderProjectList: ProjectFileListResponse = { }; export function useProjectList() { - const { data, status } = useQuery({ + const { data, status, refetch } = useQuery({ queryKey: PROJECT_LIST, queryFn: getProjects, placeholderData: placeholderProjectList, @@ -20,5 +20,5 @@ export function useProjectList() { refetchInterval: queryRefetchIntervalSlow, networkMode: 'always', }); - return { data: data ?? placeholderProjectList, status }; + return { data: data ?? placeholderProjectList, status, refetch }; } diff --git a/apps/client/src/features/app-settings/panel/project-panel/ProjectForm.tsx b/apps/client/src/features/app-settings/panel/project-panel/ProjectForm.tsx new file mode 100644 index 000000000..75a618592 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/project-panel/ProjectForm.tsx @@ -0,0 +1,68 @@ +import { useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { Button, Input } from '@chakra-ui/react'; + +import style from './ProjectPanel.module.scss'; + +export type ProjectFormValues = { + filename: string; +}; + +interface ProjectFormProps { + action: 'duplicate' | 'rename' | 'create'; + filename: string; + onCancel: () => void; + onSubmit: (values: ProjectFormValues) => Promise; + submitError: string | null; +} + +export default function ProjectForm({ action, filename, onSubmit, onCancel, submitError }: ProjectFormProps) { + const { + handleSubmit, + register, + formState: { isSubmitting, isDirty, isValid }, + setFocus, + } = useForm({ + defaultValues: { filename }, + values: { filename }, + resetOptions: { + keepDirtyValues: true, + }, + }); + + useEffect(() => { + setFocus('filename'); + }, [setFocus]); + + return ( + <> +
+ +
+ + +
+
+ {submitError && {submitError}} + + ); +} diff --git a/apps/client/src/features/app-settings/panel/project-panel/ProjectList.tsx b/apps/client/src/features/app-settings/panel/project-panel/ProjectList.tsx index 705cf31c0..0c8e6b93f 100644 --- a/apps/client/src/features/app-settings/panel/project-panel/ProjectList.tsx +++ b/apps/client/src/features/app-settings/panel/project-panel/ProjectList.tsx @@ -1,19 +1,72 @@ -import { IconButton, Menu, MenuButton, MenuItem, MenuList } from '@chakra-ui/react'; -import { IoEllipsisHorizontal } from '@react-icons/all-files/io5/IoEllipsisHorizontal'; +import { useMemo, useState } from 'react'; +import { maybeAxiosError } from '../../../../common/api/apiUtils'; +import { createProject } from '../../../../common/api/ontimeApi'; import { useProjectList } from '../../../../common/hooks-query/useProjectList'; import * as Panel from '../PanelUtils'; +import ProjectForm, { ProjectFormValues } from './ProjectForm'; +import ProjectListItem, { EditMode } from './ProjectListItem'; + import style from './ProjectPanel.module.scss'; -export default function ProjectList() { - const { data } = useProjectList(); +interface ProjectListProps { + isCreatingProject: boolean; + onToggleCreate: () => void; +} + +export default function ProjectList({ isCreatingProject, onToggleCreate }: ProjectListProps) { + const { data, refetch } = useProjectList(); const { files, lastLoadedProject } = data; - // extract currently loaded from file list - const currentlyLoadedIndex = files.findIndex((project) => project.filename === lastLoadedProject); - const projectFiles = [...files]; - const current = projectFiles.splice(currentlyLoadedIndex, 1)[0]; + const [editingMode, setEditingMode] = useState(null); + const [editingFilename, setEditingFilename] = useState(null); + const [submitError, setSubmitError] = useState(null); + + const handleToggleCreateMode = () => { + onToggleCreate(); + setSubmitError(null); + }; + + const handleSubmitCreate = async (values: ProjectFormValues) => { + try { + setSubmitError(null); + const filename = values.filename.trim(); + if (!filename) { + setSubmitError('Project name cannot be empty'); + return; + } + await createProject(filename); + await refetch(); + handleToggleCreateMode(); + } catch (error) { + setSubmitError(maybeAxiosError(error)); + } + }; + + const handleToggleEditMode = (editMode: EditMode, filename: string | null) => { + setEditingMode((prev) => (prev === editMode && filename === editingFilename ? null : editMode)); + setEditingFilename(filename); + }; + + const handleClear = () => { + setEditingMode(null); + setEditingFilename(null); + }; + + const handleRefetch = async () => { + await refetch(); + }; + + const reorderedProjectFiles = useMemo(() => { + if (!data?.files?.length) return []; + + const currentlyLoadedIndex = files.findIndex((project) => project.filename === lastLoadedProject); + const projectFiles = [...files]; + const current = projectFiles.splice(currentlyLoadedIndex, 1)?.[0]; + + return [current, ...projectFiles]; + }, [data.files.length, files, lastLoadedProject]); return ( @@ -26,51 +79,35 @@ export default function ProjectList() { - {current && ( - - {current.filename} - {new Date(current.createdAt).toLocaleString()} - {new Date(current.updatedAt).toLocaleString()} - - + {isCreatingProject ? ( + + + + {submitError && {submitError}} - )} - {projectFiles.map((project) => { - const createdAt = new Date(project.createdAt).toLocaleString(); - const updatedAt = new Date(project.updatedAt).toLocaleString(); - return ( - - {project.filename} - {createdAt} - {updatedAt} - - - - - ); - })} + ) : null} + {reorderedProjectFiles.map((project) => ( + + ))} ); } - -function ActionMenu() { - return ( - - } - variant='ontime-ghosted' - size='sm' - /> - - Load - Rename - Duplicate - Delete - - - ); -} diff --git a/apps/client/src/features/app-settings/panel/project-panel/ProjectListItem.tsx b/apps/client/src/features/app-settings/panel/project-panel/ProjectListItem.tsx new file mode 100644 index 000000000..ca7d0b7d6 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/project-panel/ProjectListItem.tsx @@ -0,0 +1,163 @@ +import { useState } from 'react'; +import { IconButton, Menu, MenuButton, MenuItem, MenuList } from '@chakra-ui/react'; +import { IoEllipsisHorizontal } from '@react-icons/all-files/io5/IoEllipsisHorizontal'; + +import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/apiUtils'; +import { deleteProject, duplicateProject, loadProject, renameProject } from '../../../../common/api/ontimeApi'; + +import ProjectForm, { ProjectFormValues } from './ProjectForm'; + +import style from './ProjectPanel.module.scss'; + +export type EditMode = 'rename' | 'duplicate' | null; + +interface ProjectListItemProps { + current?: boolean; + filename: string; + createdAt: string; + updatedAt: string; + onToggleEditMode: (editMode: EditMode, filename: string | null) => void; + onSubmit: () => void; + onRefetch: () => Promise; + editingFilename: string | null; + editingMode: EditMode | null; +} + +export default function ProjectListItem({ + current, + createdAt, + editingFilename, + editingMode, + filename, + onRefetch, + onSubmit, + onToggleEditMode, + updatedAt, +}: ProjectListItemProps) { + const [submitError, setSubmitError] = useState(null); + + const handleSubmitRename = async (values: ProjectFormValues) => { + try { + setSubmitError(null); + + if (!values.filename) { + setSubmitError('Filename cannot be blank'); + return; + } + await renameProject(filename, values.filename); + await onRefetch(); + onSubmit(); + } catch (error) { + setSubmitError(maybeAxiosError(error)); + } + }; + + const handleSubmitDuplicate = async (values: ProjectFormValues) => { + try { + setSubmitError(null); + + if (!values.filename) { + setSubmitError('Filename cannot be blank'); + return; + } + await duplicateProject(filename, values.filename); + await onRefetch(); + onSubmit(); + } catch (error) { + setSubmitError(maybeAxiosError(error)); + } + }; + + const handleToggleEditMode = (editMode: EditMode, filename: string | null) => { + setSubmitError(null); + onToggleEditMode(editMode, filename); + }; + + const handleCancel = () => { + handleToggleEditMode(null, null); + }; + + const isCurrentlyBeingEdited = editingMode && filename === editingFilename; + + return ( + + {isCurrentlyBeingEdited ? ( + + + + ) : ( + <> + {filename} + {new Date(createdAt).toLocaleString()} + {new Date(updatedAt).toLocaleString()} + + + + + )} + + ); +} + +function ActionMenu({ + current, + filename, + onChangeEditMode, + onRefetch, +}: { + current?: boolean; + filename: string; + onChangeEditMode: (editMode: EditMode, filename: string) => void; + onRefetch: () => Promise; +}) { + const handleLoad = async () => { + await loadProject(filename); + await invalidateAllCaches(); + }; + + const handleRename = () => { + onChangeEditMode('rename', filename); + }; + + const handleDuplicate = () => { + onChangeEditMode('duplicate', filename); + }; + + const handleDelete = async () => { + await deleteProject(filename); + await onRefetch(); + }; + + return ( + + } + variant='ontime-ghosted' + size='sm' + /> + + + Load + + Rename + Duplicate + + Delete + + + + ); +} diff --git a/apps/client/src/features/app-settings/panel/project-panel/ProjectPanel.module.scss b/apps/client/src/features/app-settings/panel/project-panel/ProjectPanel.module.scss index 89232b255..a085e3acb 100644 --- a/apps/client/src/features/app-settings/panel/project-panel/ProjectPanel.module.scss +++ b/apps/client/src/features/app-settings/panel/project-panel/ProjectPanel.module.scss @@ -4,5 +4,42 @@ } .actionButton { + flex: 1; text-align: right; } + +.form { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; +} + +.formInput { + flex: 2; +} + +.actionButtons { + display: flex; + align-items: center; + gap: 0.5rem; + margin-left: 1rem; +} + +.error { + color: $red-500; +} + +.createContainer { + padding: 0.5rem 0; +} + +.saveButton { + text-transform: capitalize; +} + +.createSubmitError { + font-size: calc(1rem - 2px); + font-weight: 400; + color: $red-500; +} \ No newline at end of file diff --git a/apps/client/src/features/app-settings/panel/project-panel/ProjectPanel.tsx b/apps/client/src/features/app-settings/panel/project-panel/ProjectPanel.tsx index cbb072eaf..156bae053 100644 --- a/apps/client/src/features/app-settings/panel/project-panel/ProjectPanel.tsx +++ b/apps/client/src/features/app-settings/panel/project-panel/ProjectPanel.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { Button } from '@chakra-ui/react'; import * as Panel from '../PanelUtils'; @@ -5,6 +6,12 @@ import * as Panel from '../PanelUtils'; import ProjectList from './ProjectList'; export default function ProjectPanel() { + const [isCreatingProject, setIsCreatingProject] = useState(false); + + const handleToggleCreate = () => { + setIsCreatingProject((prev) => !prev); + }; + return ( <> Project @@ -12,9 +19,11 @@ export default function ProjectPanel() { Manage projects - + - + diff --git a/apps/server/src/controllers/ontimeController.ts b/apps/server/src/controllers/ontimeController.ts index baad54e2c..cec2325fd 100644 --- a/apps/server/src/controllers/ontimeController.ts +++ b/apps/server/src/controllers/ontimeController.ts @@ -42,6 +42,7 @@ import { deleteFile } from '../utils/parserUtils.js'; import { validateProjectFiles } from './ontimeController.validate.js'; import { dbModel } from '../models/dataModel.js'; import { sheet } from '../utils/sheetsAuth.js'; +import { removeFileExtension } from '../utils/removeFileExtension.js'; // Create controller for GET request to '/ontime/poll' // Returns data for current state @@ -491,9 +492,11 @@ export const listProjects: RequestHandler = async (_, res: Response { const result = await getProjectFiles(); - const expectedFiles = ['file1.json', 'file2.json', 'file3.json'].map((file) => ({ + const expectedFiles = ['file1', 'file2', 'file3'].map((file) => ({ filename: file, createdAt: new Date('2020-01-01').toISOString(), updatedAt: new Date('2021-01-01').toISOString(), diff --git a/apps/server/src/utils/getFileListFromFolder.ts b/apps/server/src/utils/getFileListFromFolder.ts index 6106789c2..5e1980fc3 100644 --- a/apps/server/src/utils/getFileListFromFolder.ts +++ b/apps/server/src/utils/getFileListFromFolder.ts @@ -4,6 +4,7 @@ import { getAppDataPath } from '../setup.js'; import { extname, join } from 'path'; import { readdir, stat } from 'fs/promises'; +import { removeFileExtension } from './removeFileExtension.js'; const getFilesFromFolder = async (folderPath: string) => { return await readdir(folderPath); @@ -41,7 +42,7 @@ export const getProjectFiles = async (): Promise => { const stats = await stat(filePath); projectFiles.push({ - filename: file, + filename: removeFileExtension(file), createdAt: stats.birthtime.toISOString(), updatedAt: stats.mtime.toISOString(), }); diff --git a/apps/server/src/utils/removeFileExtension.ts b/apps/server/src/utils/removeFileExtension.ts new file mode 100644 index 000000000..4c577950f --- /dev/null +++ b/apps/server/src/utils/removeFileExtension.ts @@ -0,0 +1,9 @@ +import { parse } from 'path'; + +/** + * @description Takes a filename and removes the extension + * @param {string} filename - filename with extension + */ +export const removeFileExtension = (filename: string): string => { + return parse(filename).name; +}; diff --git a/apps/server/src/utils/upload.js b/apps/server/src/utils/upload.js index 52e52892c..c9b7ff5cd 100644 --- a/apps/server/src/utils/upload.js +++ b/apps/server/src/utils/upload.js @@ -6,6 +6,28 @@ import { EXCEL_MIME, JSON_MIME } from './parser.js'; import { ensureDirectory } from './fileManagement.js'; import { getAppDataPath } from '../setup.js'; +function generateNewFileName(filePath, callback) { + let baseName = path.basename(filePath, path.extname(filePath)); + let extension = path.extname(filePath); + let counter = 1; + + const checkExistence = (newPath) => { + fs.access(newPath, fs.constants.F_OK, (err) => { + if (err) { + // File with the new name does not exist, use this name + callback(path.basename(newPath)); + } else { + // File exists, increment the counter and try again + newPath = path.join(path.dirname(filePath), `${baseName} (${++counter})${extension}`); + checkExistence(newPath); + } + }); + }; + + let newPath = path.join(path.dirname(filePath), `${baseName} (${counter})${extension}`); + checkExistence(newPath); +} + // Define multer storage object const storage = multer.diskStorage({ destination: function (req, file, cb) { @@ -25,8 +47,10 @@ const storage = multer.diskStorage({ // File does not exist, can safely proceed to this destination cb(null, uploadsPath); } else { - // File already exists, handle error - return cb(new Error('File already exists'), false); + generateNewFileName(filePath, (newName) => { + file.originalname = newName; + cb(null, uploadsPath); + }); } }); }, diff --git a/packages/types/src/api/ontime-controller/BackendResponse.type.ts b/packages/types/src/api/ontime-controller/BackendResponse.type.ts index f9bdb6150..bd1f2ac11 100644 --- a/packages/types/src/api/ontime-controller/BackendResponse.type.ts +++ b/packages/types/src/api/ontime-controller/BackendResponse.type.ts @@ -26,6 +26,8 @@ export type ProjectFileListResponse = { lastLoadedProject: string; }; -export type ErrorResponse = { +export type MessageResponse = { message: string; }; + +export type ErrorResponse = MessageResponse; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 17ed539ef..301ca85ba 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -46,6 +46,7 @@ export type { ProjectFile, ErrorResponse, ProjectFileListResponse, + MessageResponse, } from './api/ontime-controller/BackendResponse.type.js'; export type { GetRundownCached } from './api/rundown-controller/BackendResponse.type.js';