diff --git a/.gitignore b/.gitignore index 44b80288d..e8a3eb951 100644 --- a/.gitignore +++ b/.gitignore @@ -69,4 +69,6 @@ scripts/manage-test-files/file-manager-test-files/* libs/app-mtp/**/*.js -*.orig \ No newline at end of file +*.orig + +.github/copilot-instructions.md diff --git a/apps/mudita-center/src/main.ts b/apps/mudita-center/src/main.ts index c5fd9cb57..f197bc371 100644 --- a/apps/mudita-center/src/main.ts +++ b/apps/mudita-center/src/main.ts @@ -10,6 +10,8 @@ import { BrowserWindow, BrowserWindowConstructorOptions, shell, + protocol, + net, } from "electron" import { ipcMain } from "electron-better-ipc" import * as path from "path" @@ -77,6 +79,8 @@ import { registerShortcuts, } from "shared/utils" import { mockServiceEnabled, startServer, stopServer } from "e2e-mock-server" +import getAppPath from "Core/__deprecated__/main/utils/get-app-path" +import fs from "fs-extra" // AUTO DISABLED - fix me if you like :) // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call @@ -248,6 +252,33 @@ const createWindow = async () => { if (!gotTheLock) { app.quit() } else { + protocol.registerSchemesAsPrivileged([ + { + scheme: "safe-file", + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + bypassCSP: true, + }, + }, + ]) + + app.whenReady().then(() => { + protocol.handle("safe-file", async (request) => { + const fileUrl = request.url.replace("safe-file://", "file:///") + if ( + !fileUrl + .toLowerCase() + .startsWith(`file://${encodeURI(getAppPath())}`.toLowerCase()) + ) { + throw new Error( + "Access to files outside of the userData directory is not allowed." + ) + } + return net.fetch(fileUrl) + }) + }) // AUTO DISABLED - fix me if you like :) // eslint-disable-next-line @typescript-eslint/no-misused-promises app.on("ready", createWindow) @@ -262,7 +293,8 @@ if (!gotTheLock) { stopServer() }) - app.on("window-all-closed", () => { + app.on("window-all-closed", async () => { + await fs.remove(getAppPath("file-preview")) app.quit() }) diff --git a/libs/core/__deprecated__/renderer/svg/close.svg b/libs/core/__deprecated__/renderer/svg/close.svg index d03850842..6ce395abe 100644 --- a/libs/core/__deprecated__/renderer/svg/close.svg +++ b/libs/core/__deprecated__/renderer/svg/close.svg @@ -1,3 +1,5 @@ - - + + diff --git a/libs/generic-view/feature/src/lib/generic-toasts.tsx b/libs/generic-view/feature/src/lib/generic-toasts.tsx index 9ae49e3f8..44fe99861 100644 --- a/libs/generic-view/feature/src/lib/generic-toasts.tsx +++ b/libs/generic-view/feature/src/lib/generic-toasts.tsx @@ -12,6 +12,7 @@ import { isEmpty } from "lodash" import { RecursiveLayout } from "./recursive-layout" import styled from "styled-components" import { Icon, Toast, Typography } from "generic-view/ui" +import { ModalLayers } from "Core/modals-manager/constants/modal-layers.enum" const selectToastsToRender = createSelector(selectViewConfig, (config) => { return Object.entries(config || {}) @@ -68,5 +69,5 @@ const ToastsWrapper = styled.div` position: fixed; right: 3.2rem; bottom: 3.2rem; - z-index: 3; + z-index: ${ModalLayers.Default}; ` diff --git a/libs/generic-view/feature/src/lib/setup-component/with-data.tsx b/libs/generic-view/feature/src/lib/setup-component/with-data.tsx index 64a5fcd07..bfa435b12 100644 --- a/libs/generic-view/feature/src/lib/setup-component/with-data.tsx +++ b/libs/generic-view/feature/src/lib/setup-component/with-data.tsx @@ -183,7 +183,7 @@ const SelfDataProvider: FunctionComponent< for (const fieldConfig of dataProvider.fields) { const { componentField, providerField, ...config } = fieldConfig - const fieldValue = get({ data: componentData }, providerField) + const fieldValue = get(childrenProps, providerField) const value = processField(config, fieldValue) set(childrenProps, componentField, value) diff --git a/libs/generic-view/models/src/lib/common-validators.ts b/libs/generic-view/models/src/lib/common-validators.ts index 0b9b0c000..5b32ff441 100644 --- a/libs/generic-view/models/src/lib/common-validators.ts +++ b/libs/generic-view/models/src/lib/common-validators.ts @@ -131,6 +131,12 @@ const nativeActionSelectFilesValidator = z.object({ formKey: z.string().optional(), selectedFilesFieldName: z.string(), }), + postActions: z + .object({ + success: entityPostActionsValidator, + failure: entityPostActionsValidator, + }) + .optional(), }) export type NativeActionSelectFiles = z.infer< @@ -145,6 +151,12 @@ const nativeActionSelectDirectoryValidator = z.object({ formKey: z.string().optional(), selectedDirectoryFieldName: z.string(), }), + postActions: z + .object({ + success: entityPostActionsValidator, + failure: entityPostActionsValidator, + }) + .optional(), }) export type NativeActionSelectDirectory = z.infer< @@ -188,6 +200,7 @@ const filesTransferExportFilesActionValidator = z.object({ type: z.literal("export-files"), destinationPath: z.string(), entitiesType: z.string().optional(), + singleEntityId: z.string().optional(), actionId: z.string(), formOptions: z.object({ diff --git a/libs/generic-view/models/src/lib/conditional-renderer.ts b/libs/generic-view/models/src/lib/conditional-renderer.ts index 4fb8d1d59..e69343c34 100644 --- a/libs/generic-view/models/src/lib/conditional-renderer.ts +++ b/libs/generic-view/models/src/lib/conditional-renderer.ts @@ -6,11 +6,17 @@ import { z } from "zod" const dataValidator = z.object({ - render: z.boolean(), + render: z.boolean().or(z.array(z.boolean())), }) export type ConditionalRendererData = z.infer -const configValidator = z.undefined() +const configValidator = z + .object({ + multipleConditionsMethod: z.enum(["and", "or"]).optional(), + }) + .optional() + +export type ConditionalRendererConfig = z.infer export const conditionalRenderer = { key: "conditional-renderer", diff --git a/libs/generic-view/models/src/lib/form-checkbox-input.ts b/libs/generic-view/models/src/lib/form-checkbox-input.ts index c4007db40..7c2b00df8 100644 --- a/libs/generic-view/models/src/lib/form-checkbox-input.ts +++ b/libs/generic-view/models/src/lib/form-checkbox-input.ts @@ -21,6 +21,7 @@ const configValidator = z.object({ validation: inputValidation.optional(), disabled: z.boolean().optional(), size: z.enum(["small", "large"]).optional(), + inactive: z.boolean().optional(), }) export type FormCheckboxInputConfig = z.infer diff --git a/libs/generic-view/models/src/lib/table.ts b/libs/generic-view/models/src/lib/table.ts index ae272be53..d17b61e00 100644 --- a/libs/generic-view/models/src/lib/table.ts +++ b/libs/generic-view/models/src/lib/table.ts @@ -16,6 +16,18 @@ const configValidator = z.object({ selectedIdsFieldName: z.string().optional(), allIdsFieldName: z.string().optional(), }), + previewOptions: z + .object({ + enabled: z.boolean(), + entitiesType: z.string(), + entityIdFieldName: z.string(), + entityPathFieldName: z.string(), + entityTitleFieldName: z.string(), + entityMimeTypeFieldName: z.string(), + entitySizeFieldName: z.string(), + componentKey: z.string(), + }) + .optional(), }) export type TableConfig = z.infer diff --git a/libs/generic-view/store/src/index.ts b/libs/generic-view/store/src/index.ts index cb62b5484..c815758a3 100644 --- a/libs/generic-view/store/src/index.ts +++ b/libs/generic-view/store/src/index.ts @@ -21,6 +21,7 @@ export * from "./lib/file-transfer/legacy-send-file.action" export * from "./lib/file-transfer/get-file.action" export * from "./lib/file-transfer/send-files.action" export * from "./lib/file-transfer/send-files-transfer-analysis.action" +export * from "./lib/file-transfer/files-transfer.type" export * from "./lib/backup/load-backup-metadata.action" export * from "./lib/backup/restore-backup.action" export * from "./lib/backup/backup.types" diff --git a/libs/generic-view/store/src/lib/file-transfer/send-file-via-mtp.action.ts b/libs/generic-view/store/src/lib/file-transfer/send-file-via-mtp.action.ts index 482bff5e3..5fd0c7925 100644 --- a/libs/generic-view/store/src/lib/file-transfer/send-file-via-mtp.action.ts +++ b/libs/generic-view/store/src/lib/file-transfer/send-file-via-mtp.action.ts @@ -140,7 +140,7 @@ export const sendFileViaMTP = createAsyncThunk< return error } - await delay(500, signal) + await delay(250, signal) return await checkSendFileProgress() } diff --git a/libs/generic-view/theme/src/lib/color.ts b/libs/generic-view/theme/src/lib/color.ts index a5a5cd16b..6aec7671f 100644 --- a/libs/generic-view/theme/src/lib/color.ts +++ b/libs/generic-view/theme/src/lib/color.ts @@ -9,6 +9,7 @@ export const color = { blue1: "#40749A", blue2: "#6D9BBC", blue5: "#F2F7FA", + grey0: "#2a2a2a", grey1: "#3B3F42", grey2: "#6A6A6A", grey3: "#A5A5A5", diff --git a/libs/generic-view/ui/src/lib/buttons/button-base/use-button-action.ts b/libs/generic-view/ui/src/lib/buttons/button-base/use-button-action.ts index 0d0ec8a51..e5acbc222 100644 --- a/libs/generic-view/ui/src/lib/buttons/button-base/use-button-action.ts +++ b/libs/generic-view/ui/src/lib/buttons/button-base/use-button-action.ts @@ -149,7 +149,20 @@ const runActions = (actions?: ButtonActions) => { break case "select-files": { - const selected = await customActions.selectFiles(action) + const selected = await customActions.selectFiles(action, { + onSuccess: async () => { + await runActions(action.postActions?.success)( + providers, + customActions + ) + }, + onFailure: async () => { + await runActions(action.postActions?.failure)( + providers, + customActions + ) + }, + }) if (!selected) { return } @@ -158,7 +171,20 @@ const runActions = (actions?: ButtonActions) => { case "select-directory": { - const selected = await customActions.selectDirectory(action) + const selected = await customActions.selectDirectory(action, { + onSuccess: async () => { + await runActions(action.postActions?.success)( + providers, + customActions + ) + }, + onFailure: async () => { + await runActions(action.postActions?.failure)( + providers, + customActions + ) + }, + }) if (!selected) { return } diff --git a/libs/generic-view/ui/src/lib/buttons/button-base/use-export-files-button-action.ts b/libs/generic-view/ui/src/lib/buttons/button-base/use-export-files-button-action.ts index 82c4b9c7c..75995e2ca 100644 --- a/libs/generic-view/ui/src/lib/buttons/button-base/use-export-files-button-action.ts +++ b/libs/generic-view/ui/src/lib/buttons/button-base/use-export-files-button-action.ts @@ -46,9 +46,11 @@ export const useExportFilesButtonAction = () => { if (action.entitiesType === undefined) return if (deviceId === undefined) return - const selectedItems: string[] = getFormContext( - action.sourceFormKey - ).getValues(action.selectedItemsFieldName) + const selectedItems: string[] = action.singleEntityId + ? [action.singleEntityId] + : getFormContext(action.sourceFormKey).getValues( + action.selectedItemsFieldName + ) const destinationPath: string = form.getValues( action.formOptions.selectedDirectoryFieldName diff --git a/libs/generic-view/ui/src/lib/buttons/button-base/use-select-directory-button-action.ts b/libs/generic-view/ui/src/lib/buttons/button-base/use-select-directory-button-action.ts index 43127ed9c..7232d1681 100644 --- a/libs/generic-view/ui/src/lib/buttons/button-base/use-select-directory-button-action.ts +++ b/libs/generic-view/ui/src/lib/buttons/button-base/use-select-directory-button-action.ts @@ -12,7 +12,13 @@ import { ipcRenderer } from "electron-better-ipc" export const useSelectDirectoryButtonAction = () => { const getFormContext = useViewFormContext() return useCallback( - async (action: NativeActionSelectDirectory) => { + async ( + action: NativeActionSelectDirectory, + callbacks: { + onSuccess?: () => Promise + onFailure?: () => Promise + } + ) => { const downloadsPath = (await ipcRenderer.callMain( "get-downloads-path" )) as string @@ -24,6 +30,7 @@ export const useSelectDirectoryButtonAction = () => { }) if (!selectorResponse.ok || !selectorResponse.data.length) { + await callbacks.onFailure?.() return false } @@ -32,6 +39,7 @@ export const useSelectDirectoryButtonAction = () => { selectorResponse.data ) + await callbacks.onSuccess?.() return true }, [getFormContext] diff --git a/libs/generic-view/ui/src/lib/buttons/button-base/use-select-files-button-action.ts b/libs/generic-view/ui/src/lib/buttons/button-base/use-select-files-button-action.ts index f49f83a02..443b6ce27 100644 --- a/libs/generic-view/ui/src/lib/buttons/button-base/use-select-files-button-action.ts +++ b/libs/generic-view/ui/src/lib/buttons/button-base/use-select-files-button-action.ts @@ -12,7 +12,13 @@ export const useSelectFilesButtonAction = () => { const getFormContext = useViewFormContext() return useCallback( - async (action: NativeActionSelectFiles) => { + async ( + action: NativeActionSelectFiles, + callbacks: { + onSuccess?: () => Promise + onFailure?: () => Promise + } + ) => { const selectorResponse = await openFileRequest({ ...(action.multiple ? { properties: ["openFile", "multiSelections"] } @@ -27,6 +33,7 @@ export const useSelectFilesButtonAction = () => { }), }) if (!selectorResponse.ok) { + await callbacks.onFailure?.() return false } @@ -34,6 +41,7 @@ export const useSelectFilesButtonAction = () => { action.formOptions.selectedFilesFieldName, selectorResponse.data ) + await callbacks.onSuccess?.() return true }, [getFormContext] diff --git a/libs/generic-view/ui/src/lib/generated/mc-file-manager/delete-files.ts b/libs/generic-view/ui/src/lib/generated/mc-file-manager/delete-files.ts index ba156aec6..805e3a665 100644 --- a/libs/generic-view/ui/src/lib/generated/mc-file-manager/delete-files.ts +++ b/libs/generic-view/ui/src/lib/generated/mc-file-manager/delete-files.ts @@ -4,35 +4,58 @@ */ import { ComponentGenerator, IconType } from "generic-view/utils" +import { ButtonTextConfig } from "generic-view/models" + +export const generateDeleteFilesButtonActions = ( + key: string, + { singleEntityId }: { singleEntityId?: string } = {} +): ButtonTextConfig["actions"] => { + return [ + ...(singleEntityId !== undefined + ? ([ + { + type: "form-set-field", + formKey: `${key}fileListForm`, + key: "selectedItems", + value: [singleEntityId], + }, + ] as ButtonTextConfig["actions"]) + : []), + { + type: "open-modal", + modalKey: `${key}deleteModal`, + domain: "files-delete", + }, + ] +} export const generateDeleteFiles: ComponentGenerator<{ - id: string entityType: string -}> = (key, { id, entityType }) => { +}> = (key, { entityType }) => { return { - [`${key}${id}deleteModal`]: { + [`${key}deleteModal`]: { component: "modal", config: { size: "small", }, childrenKeys: [ - `${key}${id}deleteModalIcon`, - `${key}${id}deleteModalTitle`, - `${key}${id}deleteModalContent`, - `${key}${id}deleteModalButtons`, + `${key}deleteModalIcon`, + `${key}deleteModalTitle`, + `${key}deleteModalContent`, + `${key}deleteModalButtons`, ], }, - [`${key}${id}deleteModalIcon`]: { + [`${key}deleteModalIcon`]: { component: "modal.titleIcon", config: { type: IconType.Exclamation, }, }, - [`${key}${id}deleteModalTitle`]: { + [`${key}deleteModalTitle`]: { component: "modal.title", - childrenKeys: [`${key}${id}deleteModalTitleText`], + childrenKeys: [`${key}deleteModalTitleText`], }, - [`${key}${id}deleteModalTitleText`]: { + [`${key}deleteModalTitleText`]: { component: "format-message", config: { messageTemplate: @@ -40,7 +63,7 @@ export const generateDeleteFiles: ComponentGenerator<{ }, dataProvider: { source: "form-fields", - formKey: `${key}${id}fileListForm`, + formKey: `${key}fileListForm`, fields: [ { providerField: "selectedItems", @@ -50,7 +73,7 @@ export const generateDeleteFiles: ComponentGenerator<{ ], }, }, - [`${key}${id}deleteModalContent`]: { + [`${key}deleteModalContent`]: { component: "typography.p1", config: { messageTemplate: @@ -58,7 +81,7 @@ export const generateDeleteFiles: ComponentGenerator<{ }, dataProvider: { source: "form-fields", - formKey: `${key}${id}fileListForm`, + formKey: `${key}fileListForm`, fields: [ { providerField: "selectedItems", @@ -68,32 +91,32 @@ export const generateDeleteFiles: ComponentGenerator<{ ], }, }, - [`${key}${id}deleteModalButtons`]: { + [`${key}deleteModalButtons`]: { component: "modal.buttons", childrenKeys: [ - `${key}${id}deleteModalCancelButton`, - `${key}${id}deleteModalConfirmButton`, + `${key}deleteModalCancelButton`, + `${key}deleteModalConfirmButton`, ], }, - [`${key}${id}deleteModalCancelButton`]: { + [`${key}deleteModalCancelButton`]: { component: "button-secondary", config: { text: "Cancel", actions: [ { type: "close-modal", - modalKey: `${key}${id}deleteModal`, + modalKey: `${key}deleteModal`, }, ], }, }, - [`${key}${id}deleteModalConfirmButton`]: { + [`${key}deleteModalConfirmButton`]: { component: "button-primary", config: { actions: [ { type: "open-modal", - modalKey: `${key}${id}deleteProgressModal`, + modalKey: `${key}deleteProgressModal`, domain: "files-delete", }, { @@ -116,14 +139,14 @@ export const generateDeleteFiles: ComponentGenerator<{ }, { type: "open-modal", - modalKey: `${key}${id}deleteErrorModal`, + modalKey: `${key}deleteErrorModal`, }, ], }, }, ], }, - childrenKeys: [`${key}${id}deleteModalConfirmButtonText`], + childrenKeys: [`${key}deleteModalConfirmButtonText`], layout: { flexLayout: { direction: "row", @@ -132,7 +155,7 @@ export const generateDeleteFiles: ComponentGenerator<{ }, dataProvider: { source: "form-fields", - formKey: `${key}${id}fileListForm`, + formKey: `${key}fileListForm`, fields: [ { providerField: "selectedItems", @@ -141,7 +164,7 @@ export const generateDeleteFiles: ComponentGenerator<{ ], }, }, - [`${key}${id}deleteModalConfirmButtonText`]: { + [`${key}deleteModalConfirmButtonText`]: { component: "format-message", config: { messageTemplate: @@ -149,7 +172,7 @@ export const generateDeleteFiles: ComponentGenerator<{ }, dataProvider: { source: "form-fields", - formKey: `${key}${id}fileListForm`, + formKey: `${key}fileListForm`, fields: [ { providerField: "selectedItems", @@ -159,39 +182,39 @@ export const generateDeleteFiles: ComponentGenerator<{ ], }, }, - [`${key}${id}deleteProgressModal`]: { + [`${key}deleteProgressModal`]: { component: "modal", config: { size: "small", }, childrenKeys: [ - `${key}${id}deleteProgressModalIcon`, - `${key}${id}deleteProgressModalTitle`, + `${key}deleteProgressModalIcon`, + `${key}deleteProgressModalTitle`, ], }, - [`${key}${id}deleteProgressModalIcon`]: { + [`${key}deleteProgressModalIcon`]: { component: "modal.titleIcon", config: { type: IconType.SpinnerDark, }, }, - [`${key}${id}deleteProgressModalTitle`]: { + [`${key}deleteProgressModalTitle`]: { component: "modal.title", config: { text: "Deleting, please wait...", }, }, - [`${key}${id}deleteErrorModal`]: { + [`${key}deleteErrorModal`]: { component: "modal", config: { size: "small", }, - childrenKeys: [`${key}${id}deleteErrorModalContent`], + childrenKeys: [`${key}deleteErrorModalContent`], }, - [`${key}${id}deleteErrorModalContent`]: { + [`${key}deleteErrorModalContent`]: { component: "entities-delete-error", config: { - modalKey: `${key}${id}deleteErrorModal`, + modalKey: `${key}deleteErrorModal`, entitiesType: entityType, }, }, diff --git a/libs/generic-view/ui/src/lib/generated/mc-file-manager/file-export-button.ts b/libs/generic-view/ui/src/lib/generated/mc-file-manager/file-export-button.ts index 90348db65..bcaacec96 100644 --- a/libs/generic-view/ui/src/lib/generated/mc-file-manager/file-export-button.ts +++ b/libs/generic-view/ui/src/lib/generated/mc-file-manager/file-export-button.ts @@ -4,7 +4,7 @@ */ import { ComponentGenerator, IconType } from "generic-view/utils" -import { McFileManagerConfig } from "generic-view/models" +import { ButtonTextConfig, McFileManagerConfig } from "generic-view/models" import { SendFilesAction } from "../../../../../store/src/lib/file-transfer/files-transfer.type" export const generateFilesExportProcessButtonKey = (key: string) => @@ -17,74 +17,38 @@ export const generateFilesExportButtonModalKey = ( return generateFilesExportProcessButtonKey(key) + "Modal" + modalName } -export const generateFileExportProcessButton: ComponentGenerator< - Pick< - McFileManagerConfig["categories"][number], - "entityType" | "supportedFileTypes" | "directoryPath" | "label" - > & { - storagePath: string +export const generateFilesExportButtonActions = ( + key: string, + { + singleEntityId, + entityType, + exportActionId, + }: Pick & { + singleEntityId?: string + exportActionId: string } -> = (key, { directoryPath, entityType }) => { - const exportActionId = entityType + "Export" - - return { - [generateFilesExportProcessButtonKey(key)]: { - component: "button-text", - config: { - text: entityType === "applicationFiles" ? "Export APK" : "Export", - icon: IconType.Export, - actions: [ +): ButtonTextConfig["actions"] => { + return [ + ...(singleEntityId !== undefined + ? ([ { - type: "select-directory", - title: "Choose a directory to export", - formOptions: { - formKey: `${key}fileExportForm`, - selectedDirectoryFieldName: "exportPath", - }, + type: "form-set-field", + formKey: `${key}fileListForm`, + key: "selectedItems", + value: [singleEntityId], }, - { - type: "open-modal", - modalKey: generateFilesExportButtonModalKey(key, "Progress"), - }, - { - type: "export-files", - formOptions: { - formKey: `${key}fileExportForm`, - selectedDirectoryFieldName: "exportPath", - }, - destinationPath: "exportPath", - sourceFormKey: `${key}fileListForm`, - selectedItemsFieldName: "selectedItems", - entitiesType: entityType, - actionId: exportActionId, - preActions: { - validationFailure: [ - { - type: "replace-modal", - modalKey: generateFilesExportButtonModalKey( - key, - "ValidationFailure" - ), - }, - ], - }, + ] as ButtonTextConfig["actions"]) + : []), + { + type: "select-directory", + title: "Choose a directory to export", + formOptions: { + formKey: `${key}fileListForm`, + selectedDirectoryFieldName: "exportPath", + }, + ...(singleEntityId !== undefined + ? { postActions: { - success: [ - { - type: "close-modal", - modalKey: generateFilesExportButtonModalKey(key, "Progress"), - }, - { - type: "open-toast", - toastKey: `${key}FilesExportedToast`, - }, - { - type: "form-set-field", - formKey: `${key}fileListForm`, - key: "selectedItems", - value: [], - }, - ], failure: [ { type: "form-set-field", @@ -92,14 +56,92 @@ export const generateFileExportProcessButton: ComponentGenerator< key: "selectedItems", value: [], }, - { - type: "replace-modal", - modalKey: generateFilesExportButtonModalKey(key, "Finished"), - }, ], }, + } + : {}), + }, + { + type: "open-modal", + modalKey: generateFilesExportButtonModalKey(key, "Progress"), + }, + { + type: "export-files", + formOptions: { + formKey: `${key}fileListForm`, + selectedDirectoryFieldName: "exportPath", + }, + destinationPath: "exportPath", + sourceFormKey: `${key}fileListForm`, + selectedItemsFieldName: "selectedItems", + entitiesType: entityType, + singleEntityId, + actionId: exportActionId, + preActions: { + validationFailure: [ + { + type: "replace-modal", + modalKey: generateFilesExportButtonModalKey( + key, + "ValidationFailure" + ), }, ], + }, + postActions: { + success: [ + { + type: "close-modal", + modalKey: generateFilesExportButtonModalKey(key, "Progress"), + }, + { + type: "open-toast", + toastKey: `${key}FilesExportedToast`, + }, + { + type: "form-set-field", + formKey: `${key}fileListForm`, + key: "selectedItems", + value: [], + }, + ], + failure: [ + { + type: "form-set-field", + formKey: `${key}fileListForm`, + key: "selectedItems", + value: [], + }, + { + type: "replace-modal", + modalKey: generateFilesExportButtonModalKey(key, "Finished"), + }, + ], + }, + }, + ] +} + +export const generateFileExportProcessButton: ComponentGenerator< + Pick< + McFileManagerConfig["categories"][number], + "entityType" | "directoryPath" + > & { + singleEntityId?: string + exportActionId: string + } +> = (key, { directoryPath, entityType, singleEntityId, exportActionId }) => { + return { + [generateFilesExportProcessButtonKey(key)]: { + component: "button-text", + config: { + text: entityType === "applicationFiles" ? "Export APK" : "Export", + icon: IconType.Export, + actions: generateFilesExportButtonActions(key, { + singleEntityId, + entityType, + exportActionId, + }), modifiers: ["uppercase"], }, dataProvider: { @@ -113,7 +155,6 @@ export const generateFileExportProcessButton: ComponentGenerator< }, ], }, - childrenKeys: [`${key}fileExportForm`], }, [generateFilesExportButtonModalKey(key, "Progress")]: { component: "modal", @@ -209,15 +250,5 @@ export const generateFileExportProcessButton: ComponentGenerator< ], }, }, - [`${key}fileExportForm`]: { - component: "form", - config: { - formOptions: { - defaultValues: { - exportPath: "", - }, - }, - }, - }, } } diff --git a/libs/generic-view/ui/src/lib/generated/mc-file-manager/file-list.ts b/libs/generic-view/ui/src/lib/generated/mc-file-manager/file-list.ts index 7143b269e..e216635d0 100644 --- a/libs/generic-view/ui/src/lib/generated/mc-file-manager/file-list.ts +++ b/libs/generic-view/ui/src/lib/generated/mc-file-manager/file-list.ts @@ -4,7 +4,10 @@ */ import { ComponentGenerator, IconType, Subview } from "generic-view/utils" -import { generateDeleteFiles } from "./delete-files" +import { + generateDeleteFiles, + generateDeleteFilesButtonActions, +} from "./delete-files" import { McFileManagerConfig } from "generic-view/models" import { generateFileUploadProcessButton, @@ -64,6 +67,8 @@ const generateFileList: ComponentGenerator< filesToUpload: [], activeFileName: null, activeFilePath: null, + exportPath: "", + previewMode: false, }, }, }, @@ -138,17 +143,26 @@ const generateFileList: ComponentGenerator< }, [`${key}${id}fileListPanelDefaultMode`]: { component: "conditional-renderer", + config: { + multipleConditionsMethod: "or", + }, childrenKeys: [`${key}${id}fileListPanel`], dataProvider: { source: "form-fields", fields: [ { providerField: "selectedItems", - componentField: "data.render", + componentField: "data.render[0]", modifier: "length", condition: "eq", value: 0, }, + { + providerField: "previewMode", + componentField: "data.render[1]", + condition: "eq", + value: true, + }, ], }, }, @@ -198,17 +212,26 @@ const generateFileList: ComponentGenerator< }), [`${key}${id}fileListPanelSelectMode`]: { component: "conditional-renderer", + config: { + multipleConditionsMethod: "and", + }, childrenKeys: [`${key}${id}fileListPanelSelector`], dataProvider: { source: "form-fields", fields: [ { providerField: "selectedItems", - componentField: "data.render", + componentField: "data.render[0]", modifier: "length", condition: "gt", value: 0, }, + { + providerField: "previewMode", + componentField: "data.render[1]", + condition: "eq", + value: false, + }, ], }, }, @@ -274,27 +297,18 @@ const generateFileList: ComponentGenerator< ...generateFileExportProcessButton(`${key}${id}`, { directoryPath, entityType, - storagePath, - supportedFileTypes, - label, + exportActionId: entityType + "Export", }), [`${key}${id}deleteButton`]: { component: "button-text", config: { text: entityType === "applicationFiles" ? "Delete APK" : "Delete", icon: IconType.Delete, - actions: [ - { - type: "open-modal", - modalKey: `${key}${id}deleteModal`, - domain: "files-delete", - }, - ], + actions: generateDeleteFilesButtonActions(`${key}${id}`), modifiers: ["uppercase"], }, }, - ...generateDeleteFiles(key, { - id, + ...generateDeleteFiles(`${key}${id}`, { entityType, }), [`${key}${id}fileListEmptyState`]: { @@ -340,6 +354,16 @@ const generateFileList: ComponentGenerator< selectedIdsFieldName: "selectedItems", allIdsFieldName: "allItems", }, + previewOptions: { + enabled: entityType === "imageFiles", + entitiesType: entityType, + entityIdFieldName: "id", + entityPathFieldName: "filePath", + entityTitleFieldName: "fileName", + entityMimeTypeFieldName: "mimeType", + entitySizeFieldName: "fileSize", + componentKey: `${key}${id}`, + }, }, dataProvider: { entitiesType: entityType, @@ -487,6 +511,18 @@ const generateFileList: ComponentGenerator< }, ], }, + dataProviderSecondary: { + source: "form-fields", + formKey: `${key}${id}fileListForm`, + fields: [ + { + providerField: "previewMode", + componentField: "config.inactive", + condition: "eq", + value: true, + }, + ], + }, }, [`${key}${id}columnName`]: { component: "table.cell", diff --git a/libs/generic-view/ui/src/lib/icon/get-icon.helper.tsx b/libs/generic-view/ui/src/lib/icon/get-icon.helper.tsx index e0ee8ec40..fea8939c8 100644 --- a/libs/generic-view/ui/src/lib/icon/get-icon.helper.tsx +++ b/libs/generic-view/ui/src/lib/icon/get-icon.helper.tsx @@ -33,6 +33,8 @@ import Spinner from "Core/__deprecated__/renderer/svg/spinner.svg" import Contact from "Core/__deprecated__/renderer/svg/contact.svg" import ContactsBook from "Core/__deprecated__/renderer/svg/menu-contacts.svg" +import ArrowLeft from "./svg/arrow-left.svg" +import ArrowRight from "./svg/arrow-right.svg" import Backup from "./svg/backup.svg" import Book from "./svg/book.svg" import MusicNote from "./svg/music-note.svg" @@ -67,6 +69,8 @@ import Export from "./svg/export.svg" import { IconType } from "generic-view/utils" const typeToIcon: Record = { + [IconType.ArrowLeft]: ArrowLeft, + [IconType.ArrowRight]: ArrowRight, [IconType.Battery0]: BatteryEmpty, [IconType.Battery1]: BatteryVeryLow, [IconType.Battery2]: BatteryLow, diff --git a/libs/generic-view/ui/src/lib/icon/svg/arrow-left.svg b/libs/generic-view/ui/src/lib/icon/svg/arrow-left.svg new file mode 100644 index 000000000..f582830b9 --- /dev/null +++ b/libs/generic-view/ui/src/lib/icon/svg/arrow-left.svg @@ -0,0 +1,5 @@ + + + diff --git a/libs/generic-view/ui/src/lib/icon/svg/arrow-right.svg b/libs/generic-view/ui/src/lib/icon/svg/arrow-right.svg new file mode 100644 index 000000000..f67aa5181 --- /dev/null +++ b/libs/generic-view/ui/src/lib/icon/svg/arrow-right.svg @@ -0,0 +1,5 @@ + + + diff --git a/libs/generic-view/ui/src/lib/icon/svg/export.svg b/libs/generic-view/ui/src/lib/icon/svg/export.svg index e4e3c4415..d7c0473e2 100644 --- a/libs/generic-view/ui/src/lib/icon/svg/export.svg +++ b/libs/generic-view/ui/src/lib/icon/svg/export.svg @@ -1,9 +1,15 @@ - - - - - - - + + + + + + + diff --git a/libs/generic-view/ui/src/lib/interactive/conditional-renderer.tsx b/libs/generic-view/ui/src/lib/interactive/conditional-renderer.tsx index b379943f1..b6c543db0 100644 --- a/libs/generic-view/ui/src/lib/interactive/conditional-renderer.tsx +++ b/libs/generic-view/ui/src/lib/interactive/conditional-renderer.tsx @@ -4,14 +4,25 @@ */ import { APIFC } from "generic-view/utils" -import { ConditionalRendererData } from "generic-view/models" +import { + ConditionalRendererConfig, + ConditionalRendererData, +} from "generic-view/models" -export const ConditionalRenderer: APIFC = ({ - children, - data, -}) => { - if (data?.render) { - return children +export const ConditionalRenderer: APIFC< + ConditionalRendererData, + ConditionalRendererConfig +> = ({ children, data, config }) => { + const render = data?.render || false + + if (typeof render === "boolean") { + return render ? children : null + } + if (config?.multipleConditionsMethod === "and") { + return render.every(Boolean) ? children : null + } + if (config?.multipleConditionsMethod === "or") { + return render.some(Boolean) ? children : null } return null } diff --git a/libs/generic-view/ui/src/lib/interactive/form/input/checkbox-input.tsx b/libs/generic-view/ui/src/lib/interactive/form/input/checkbox-input.tsx index 61d06239c..6f24cb1a2 100644 --- a/libs/generic-view/ui/src/lib/interactive/form/input/checkbox-input.tsx +++ b/libs/generic-view/ui/src/lib/interactive/form/input/checkbox-input.tsx @@ -48,6 +48,9 @@ export const CheckboxInput: APIFC = ({ const handleChange = useCallback( (e: React.ChangeEvent) => { + if (config.inactive) { + return + } const checked = e.target.checked config.onToggle?.(checked) @@ -87,6 +90,9 @@ export const CheckboxInput: APIFC = ({ ) useEffect(() => { + if (config.inactive) { + return + } if (multiSelect) { if (multiSelect.selectedValues.length === multiSelect.allValues.length) { setValue(inputName, true) @@ -99,7 +105,7 @@ export const CheckboxInput: APIFC = ({ multiSelect.selectedValues.length < multiSelect.allValues.length } } - }, [fieldRegistrar.ref, inputName, multiSelect, setValue]) + }, [config.inactive, fieldRegistrar.ref, inputName, multiSelect, setValue]) return ( = ({ data-testid={CheckboxTestIds.Checkbox} type={"checkbox"} value={config.value} - checked={config.checked} - disabled={config.disabled} + checked={!config.inactive && config.checked} + disabled={config.disabled || config.inactive} {...fieldRegistrar} onChange={handleChange} ref={handleRef} diff --git a/libs/generic-view/ui/src/lib/predefined/file-preview/file-preview.tsx b/libs/generic-view/ui/src/lib/predefined/file-preview/file-preview.tsx new file mode 100644 index 000000000..2b02b8548 --- /dev/null +++ b/libs/generic-view/ui/src/lib/predefined/file-preview/file-preview.tsx @@ -0,0 +1,358 @@ +/** + * Copyright (c) Mudita sp. z o.o. All rights reserved. + * For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md + */ + +import React, { + FunctionComponent, + memo, + useCallback, + useEffect, + useMemo, + useRef, +} from "react" +import { useSelector } from "react-redux" +import styled, { css } from "styled-components" +import { selectEntityData, selectFilesTransferMode } from "generic-view/store" +import { IconType } from "generic-view/utils" +import { Modal } from "../../interactive/modal/modal" +import { IconButton } from "../../shared/button" +import { Icon } from "../../icon/icon" +import { Typography } from "../../typography" +import { SpinnerLoader } from "../../shared/spinner-loader" +import { ImagePreview } from "./image-preview" +import { AnimatePresence, motion } from "motion/react" +import { useFilesPreview, UseFilesPreviewParams } from "./use-files-preview" +import { activeDeviceIdSelector } from "active-device-registry/feature" +import { ReduxRootState } from "Core/__deprecated__/renderer/store" +import { ButtonIcon } from "../../buttons/button-icon" +import { generateFilesExportButtonActions } from "../../generated/mc-file-manager/file-export-button" +import { generateDeleteFilesButtonActions } from "../../generated/mc-file-manager/delete-files" +import { ModalLayers } from "Core/modals-manager/constants/modal-layers.enum" + +interface Props { + componentKey: string + entitiesConfig: UseFilesPreviewParams["entitiesConfig"] + items: string[] + activeItem: string | undefined + onActiveItemChange: (item: string | undefined) => void + actions?: React.ReactNode +} + +export const FilePreview: FunctionComponent = memo( + ({ items, activeItem, onActiveItemChange, entitiesConfig, componentKey }) => { + const deviceId = useSelector(activeDeviceIdSelector) + const entity = useSelector((state: ReduxRootState) => { + if (!activeItem || !deviceId) return undefined + return selectEntityData(state, { + deviceId, + entitiesType: entitiesConfig.type, + entityId: activeItem, + }) + }) + const nextIdReference = useRef() + + // TODO: Handle file transfer mode + const fileTransferMode = useSelector(selectFilesTransferMode) + + const { + data: fileInfo, + nextId, + previousId, + } = useFilesPreview({ + items, + activeItem, + entitiesConfig, + }) + + const isLoading = !fileInfo + + const entityType = useMemo(() => { + if (!entity) return "" + return entity[entitiesConfig.mimeTypeField] as string + }, [entitiesConfig.mimeTypeField, entity]) + + const entityName = useMemo(() => { + if (!entity) return "" + return entity[entitiesConfig.titleField] as string + }, [entitiesConfig.titleField, entity]) + + const handleClose = useCallback(() => { + onActiveItemChange(undefined) + }, [onActiveItemChange]) + + const handleError = useCallback(() => { + // TODO: Handle errors + }, []) + + const handlePreviousFile = useCallback(() => { + onActiveItemChange(previousId) + }, [onActiveItemChange, previousId]) + + const handleNextFile = useCallback(() => { + onActiveItemChange(nextIdReference.current || nextId) + }, [nextId, onActiveItemChange]) + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key === "ArrowLeft") { + handlePreviousFile() + } else if (event.key === "ArrowRight") { + handleNextFile() + } + }, + [handleNextFile, handlePreviousFile] + ) + + useEffect(() => { + document.addEventListener("keydown", handleKeyDown) + if (!activeItem) { + document.removeEventListener("keydown", handleKeyDown) + } + return () => { + document.removeEventListener("keydown", handleKeyDown) + } + }, [activeItem, handleKeyDown]) + + return ( + + +
+ + {entityName} + + + + +
+
+ + + + + {!isLoading && ( + + {entityType.startsWith("image") && ( + + )} + + )} + +
+ {items.length > 1 && ( + + + + + + + + + )} +
+ + +
+
+
+ ) + } +) + +const frameCommonStyles = css` + position: absolute; + z-index: 3; + width: 100%; + height: 8rem; + left: 0; + padding: 0 2.4rem; + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-end; + gap: 2.4rem; + + opacity: 0; + visibility: hidden; + transition-property: opacity, visibility; + transition-duration: 0.15s; + transition-timing-function: ease-in-out; + + &:before { + content: ""; + z-index: -1; + position: absolute; + width: 100%; + height: 100%; + left: 0; + } +` + +const Header = styled.header` + ${frameCommonStyles}; + top: 0; + + &:before { + background: linear-gradient( + 0deg, + rgba(0, 0, 0, 0) 0%, + rgba(0, 0, 0, 0.34) 100% + ); + filter: drop-shadow(0 1rem 5rem rgba(0, 0, 0, 0.08)); + } + + p { + flex: 1; + color: ${({ theme }) => theme.color.white} !important; + text-align: left !important; + } +` + +const Footer = styled.footer` + ${frameCommonStyles}; + bottom: 0; + + &:before { + background: linear-gradient( + 0deg, + rgba(0, 0, 0, 0) -2.5%, + rgba(0, 0, 0, 0.34) 100% + ); + filter: drop-shadow(0 1rem 5rem rgba(0, 0, 0, 0.08)); + transform: rotate(-180deg); + } + + svg { + color: ${({ theme }) => theme.color.white} !important; + } +` + +const Navigation = styled.nav` + position: absolute; + z-index: 2; + top: 50%; + left: 0; + transform: translateY(-50%); + width: 100%; + padding: 0 2.4rem; + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + + opacity: 0; + visibility: hidden; + transition-property: opacity, visibility; + transition-duration: 0.15s; + transition-timing-function: ease-in-out; +` + +const NavigationButton = styled.button` + cursor: pointer; + appearance: none; + border: none; + width: 4rem; + height: 4rem; + border-radius: 50%; + background-color: ${({ theme }) => theme.color.grey1}; + display: flex; + align-items: center; + justify-content: center; + margin: 0; + padding: 0; + outline: none; +` + +const ModalContent = styled.section` + position: relative; + width: 100%; + height: 100%; + overflow: hidden; + background-color: ${({ theme }) => theme.color.grey0}; + + &:hover { + ${Header}, ${Footer}, ${Navigation} { + opacity: 1; + visibility: visible; + } + } +` + +const Loader = styled.div` + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 1; +` + +const PreviewWrapper = styled(motion.div)` + position: relative; + width: 100%; + height: 100%; + z-index: 2; +` + +const Main = styled.main` + position: relative; + z-index: 1; + width: 100%; + height: 64rem; + display: flex; + align-items: center; + justify-content: center; + background-color: ${({ theme }) => theme.color.grey0}; +` diff --git a/libs/generic-view/ui/src/lib/predefined/file-preview/image-preview.tsx b/libs/generic-view/ui/src/lib/predefined/file-preview/image-preview.tsx new file mode 100644 index 000000000..2afd87367 --- /dev/null +++ b/libs/generic-view/ui/src/lib/predefined/file-preview/image-preview.tsx @@ -0,0 +1,72 @@ +/** + * Copyright (c) Mudita sp. z o.o. All rights reserved. + * For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md + */ + +import React, { + FunctionComponent, + useCallback, + useEffect, + useRef, + useState, +} from "react" +import styled from "styled-components" + +interface Props { + src?: string + onError?: () => void +} + +export const ImagePreview: FunctionComponent = ({ src, onError }) => { + const loadedTimeoutRef = useRef() + const [loaded, setLoaded] = useState(false) + + const onLoad = useCallback(() => { + loadedTimeoutRef.current = setTimeout(() => { + // Ensure bigger images are fully rendered + setLoaded(true) + }, 100) + }, []) + + useEffect(() => { + clearTimeout(loadedTimeoutRef.current) + setLoaded(false) + }, [src]) + + return ( + + + + + ) +} + +const Wrapper = styled.div<{ $loaded?: boolean }>` + position: relative; + width: 100%; + height: 100%; + opacity: ${({ $loaded }) => ($loaded ? 1 : 0)}; + transition: opacity 0.5s ease-in-out; +` + +const MainImage = styled.img` + width: 100%; + height: 100%; + object-fit: contain; + display: block; + margin: 0; + position: relative; + z-index: 1; +` + +const BackgroundImage = styled.div<{ $url?: string }>` + position: absolute; + width: 100%; + height: 100%; + z-index: 0; + + background-image: url("${({ $url }) => $url}"); + background-position: center; + background-size: cover; + filter: blur(5rem) brightness(0.4); +` diff --git a/libs/generic-view/ui/src/lib/predefined/file-preview/use-file-preview-download.tsx b/libs/generic-view/ui/src/lib/predefined/file-preview/use-file-preview-download.tsx new file mode 100644 index 000000000..8f50b188d --- /dev/null +++ b/libs/generic-view/ui/src/lib/predefined/file-preview/use-file-preview-download.tsx @@ -0,0 +1,214 @@ +/** + * Copyright (c) Mudita sp. z o.o. All rights reserved. + * For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md + */ + +import { useDispatch, useSelector, useStore } from "react-redux" +import { + FileWithPath, + selectEntityData, + sendFiles, + SendFilesAction, +} from "generic-view/store" +import { AppDispatch, ReduxRootState } from "Core/__deprecated__/renderer/store" +import { activeDeviceIdSelector } from "active-device-registry/feature" +import { useCallback, useRef } from "react" +import { checkPath, removeDirectory } from "system-utils/feature" +import { + isMtpPathInternal, + sliceMtpPaths, +} from "../../buttons/button-base/file-transfer-paths-helper" +import path from "node:path" + +export interface FilePreviewResponse { + id: string + path: string + name: string + type: string +} + +export interface UseFilePreviewDownloadParams { + tempDirectoryPath?: string + actionId: string + entitiesType: string + fields: { + idField: string + pathField: string + titleField: string + mimeTypeField: string + sizeField: string + } +} + +export const useFilePreviewDownload = ({ + tempDirectoryPath, + actionId, + entitiesType, + fields, +}: UseFilePreviewDownloadParams) => { + const dispatch = useDispatch() + const store = useStore() + const abortReferences = useRef>({}) + const deviceId = useSelector(activeDeviceIdSelector) + + const getFilePath = useCallback( + (entityFilePath: string) => { + if (!tempDirectoryPath) { + return undefined + } + const deviceFilePath = (entityFilePath as string).split("/") + const fileName = deviceFilePath.pop() as string + const nativeDir = path.join(tempDirectoryPath, ...deviceFilePath) + const nativePath = path.join(nativeDir, fileName) + return { + nativeDir, + nativePath: nativePath, + safePath: + process.platform === "win32" + ? nativePath + : `safe-file://${nativePath}`, + } + }, + [tempDirectoryPath] + ) + + const downloadFile = useCallback( + async ( + entityId: string, + onFileNameFound?: (name: string) => void + ): Promise => { + if (!deviceId || !tempDirectoryPath) { + return + } + const entity = selectEntityData(store.getState(), { + deviceId, + entitiesType, + entityId, + }) + + if (!entity || !("filePath" in entity) || !("fileName" in entity)) { + return + } + + const fileName = entity[fields.titleField] as string + onFileNameFound?.(fileName) + const fileType = (entity[fields.mimeTypeField] as string) + .split("/")[0] + .toLowerCase() + const filePath = getFilePath(entity[fields.pathField] as string) + if (!filePath) { + return + } + + if ((await checkPath(filePath.nativePath)).data) { + return { + id: entityId, + path: filePath.safePath, + name: fileName, + type: fileType, + } + } + await checkPath(filePath.nativeDir, true) + + const fileData: FileWithPath = { + id: entityId, + path: sliceMtpPaths( + entity[fields.pathField] as string, + entity.isInternal as boolean + ), + name: String(entity[fields.titleField]), + size: Number(entity[fields.sizeField]), + groupId: actionId, + } + + const promise = dispatch( + sendFiles({ + files: [fileData], + destinationPath: filePath.nativeDir, + actionId, + entitiesType, + isMtpPathInternal: isMtpPathInternal( + entity[fields.pathField] as string + ), + actionType: SendFilesAction.ActionExport, + }) + ) as unknown as ReturnType> + + abortReferences.current[entityId] = ( + promise as unknown as { + abort: VoidFunction + } + ).abort + + const response = await promise + + if (response.meta.requestStatus === "rejected") { + return + } + + return { + id: entityId, + path: filePath.safePath, + name: fileName, + type: fileType, + } + }, + [ + actionId, + deviceId, + dispatch, + entitiesType, + fields.mimeTypeField, + fields.pathField, + fields.sizeField, + fields.titleField, + getFilePath, + store, + tempDirectoryPath, + ] + ) + + const removePreviewFile = useCallback( + async (entityId: string) => { + if (!deviceId || !tempDirectoryPath) { + return + } + const entity = selectEntityData(store.getState(), { + deviceId, + entitiesType, + entityId, + }) + if (!entity) { + return + } + const path = getFilePath(entity[fields.pathField] as string)?.nativePath + if (path) { + await removeDirectory(path) + } + }, + [ + deviceId, + entitiesType, + fields.pathField, + getFilePath, + store, + tempDirectoryPath, + ] + ) + + const cancelDownload = useCallback( + async (entityId: string) => { + if (abortReferences.current[entityId]) { + abortReferences.current[entityId]() + delete abortReferences.current[entityId] + } + await removePreviewFile(entityId) + }, + [removePreviewFile] + ) + + return { + downloadFile, + cancelDownload, + } +} diff --git a/libs/generic-view/ui/src/lib/predefined/file-preview/use-files-preview.tsx b/libs/generic-view/ui/src/lib/predefined/file-preview/use-files-preview.tsx new file mode 100644 index 000000000..6a9d2841e --- /dev/null +++ b/libs/generic-view/ui/src/lib/predefined/file-preview/use-files-preview.tsx @@ -0,0 +1,189 @@ +/** + * Copyright (c) Mudita sp. z o.o. All rights reserved. + * For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md + */ + +import { useDispatch } from "react-redux" +import { sendFilesClear } from "generic-view/store" +import { AppDispatch } from "Core/__deprecated__/renderer/store" +import { useCallback, useEffect, useMemo, useState } from "react" +import { checkPath, getAppPath, removeDirectory } from "system-utils/feature" +import { uniqBy } from "lodash" +import { + FilePreviewResponse, + useFilePreviewDownload, + UseFilePreviewDownloadParams, +} from "./use-file-preview-download" +import { Queue } from "shared/utils" + +export enum QueuePriority { + Current = 2, + Next = 1, + Prev = 0, +} + +export interface UseFilesPreviewParams { + items: string[] + activeItem?: string + entitiesConfig: UseFilePreviewDownloadParams["fields"] & { + type: string + } +} + +export const useFilesPreview = ({ + items, + activeItem: activeItem, + entitiesConfig, +}: UseFilesPreviewParams) => { + const actionId = entitiesConfig.type + "Preview" + const dispatch = useDispatch() + + const [tempDirectoryPath, setTempDirectoryPath] = useState() + const [downloadedItems, setDownloadedItems] = useState( + [] + ) + const downloadedItemsDependency = JSON.stringify( + downloadedItems.map((item) => item.id) + ) + + const { downloadFile, cancelDownload } = useFilePreviewDownload({ + actionId, + tempDirectoryPath, + entitiesType: entitiesConfig.type, + fields: entitiesConfig, + }) + + const queue = useMemo(() => { + return new Queue({ interval: 100, capacity: 3 }) + }, []) + + const getNearestFiles = useCallback( + (item?: string) => { + if (!item || !items.length) { + return { previous: undefined, next: undefined } + } + const currentIndex = items.indexOf(item) + return { + previous: items[(currentIndex - 1 + items.length) % items.length], + next: items[(currentIndex + 1) % items.length], + } + }, + [items] + ) + + const nearestFiles = useMemo(() => { + return getNearestFiles(activeItem) + }, [activeItem, getNearestFiles]) + + const currentFile = useMemo(() => { + if (!activeItem) return undefined + return downloadedItems.find((item) => item.id === activeItem) + // eslint-disable-next-line + }, [activeItem, downloadedItemsDependency]) + + const getFilePriority = useCallback( + (fileId: string) => { + if (fileId === activeItem) { + return QueuePriority.Current + } + const nearest = getNearestFiles(activeItem) + if (fileId === nearest.next) { + return QueuePriority.Next + } + return QueuePriority.Prev + }, + [activeItem, getNearestFiles] + ) + + const downloadFiles = useCallback( + (filesIds: string[]) => { + for (const fileId of filesIds) { + void queue.add({ + id: fileId, + task: async () => { + const fileInfo = await downloadFile(fileId) + if (fileInfo) { + setDownloadedItems((prev) => uniqBy([...prev, fileInfo], "id")) + } + }, + priority: getFilePriority(fileId), + }) + } + }, + [downloadFile, getFilePriority, queue] + ) + + useEffect(() => { + if (!currentFile?.id) return + + const filesToRemove = downloadedItems.filter((item) => { + return ( + item.id !== currentFile.id && + item.id !== nearestFiles.next && + item.id !== nearestFiles.previous + ) + }) + + if (filesToRemove.length > 0) { + for (const file of filesToRemove) { + void cancelDownload(file.id) + setDownloadedItems((prev) => prev.filter((item) => item.id !== file.id)) + } + } + // eslint-disable-next-line + }, [ + currentFile?.id, + cancelDownload, + downloadedItemsDependency, + nearestFiles.next, + nearestFiles.previous, + ]) + + useEffect(() => { + if (!activeItem) return + + const filesToDownload = [ + activeItem, + nearestFiles.next, + nearestFiles.previous, + ].filter(Boolean) as string[] + + downloadFiles(filesToDownload) + }, [activeItem, nearestFiles.next, nearestFiles.previous, downloadFiles]) + + const ensureTempDirectory = useCallback(async () => { + const destinationPath = await getAppPath("filePreview") + if (destinationPath.ok) { + await checkPath(destinationPath.data, true) + setTempDirectoryPath(destinationPath.data) + } + }, []) + + const clearTempDirectory = useCallback(async () => { + dispatch(sendFilesClear({ groupId: actionId })) + if (tempDirectoryPath) { + await removeDirectory(tempDirectoryPath) + } + }, [actionId, dispatch, tempDirectoryPath]) + + useEffect(() => { + if (!activeItem) { + void clearTempDirectory() + setDownloadedItems([]) + } + }, [activeItem, clearTempDirectory]) + + useEffect(() => { + void ensureTempDirectory() + return () => { + queue.clear() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + return { + data: currentFile, + nextId: nearestFiles.next, + previousId: nearestFiles.previous, + } +} diff --git a/libs/generic-view/ui/src/lib/segment-bar/segment-bar.tsx b/libs/generic-view/ui/src/lib/segment-bar/segment-bar.tsx index 4bbd0f649..5a8667822 100644 --- a/libs/generic-view/ui/src/lib/segment-bar/segment-bar.tsx +++ b/libs/generic-view/ui/src/lib/segment-bar/segment-bar.tsx @@ -44,7 +44,13 @@ export const SegmentBar: APIFC = ({ }, [segments, containerWidth]) return ( - + {computedSegments.map((segment, index) => ( ))} @@ -57,6 +63,7 @@ const Wrapper = styled.div<{ height: string }>` position: relative; + z-index: 2; width: ${(props) => props.width}; height: ${(props) => props.height}; ` diff --git a/libs/generic-view/ui/src/lib/table/table.tsx b/libs/generic-view/ui/src/lib/table/table.tsx index 3e1356cd3..dbf25de88 100644 --- a/libs/generic-view/ui/src/lib/table/table.tsx +++ b/libs/generic-view/ui/src/lib/table/table.tsx @@ -13,7 +13,7 @@ import React, { useRef, useState, } from "react" -import styled from "styled-components" +import styled, { css } from "styled-components" import { difference, intersection } from "lodash" import { TableTestIds } from "e2e-test-ids" import { APIFC, useViewFormContext } from "generic-view/utils" @@ -28,32 +28,47 @@ import { listRawItemStyles, } from "../list/list-item" import { toastAnimationDuration } from "../interactive/toast/toast" +import { FilePreview } from "../predefined/file-preview/file-preview" const rowHeight = 64 export const Table: APIFC & { Cell: typeof TableCell HeaderCell: typeof TableCell -} = ({ data = [], config, children, ...props }) => { +} = ({ + data = [], + config: { formOptions, previewOptions }, + children, + ...props +}) => { const getFormContext = useViewFormContext() - const formContext = getFormContext(config.formOptions.formKey) + const formContext = getFormContext(formOptions.formKey) const scrollWrapperRef = useRef(null) const [visibleRowsBounds, setVisibleRowsBounds] = useState<[number, number]>([ -1, -1, ]) - const { formOptions } = config - const { activeIdFieldName } = formOptions + const [activePreviewId, setActivePreviewId] = useState() + const nextActivePreviewId = useRef(undefined) + const { activeIdFieldName } = formOptions || {} const isClickable = Boolean(activeIdFieldName) - const activeRowId = activeIdFieldName ? formContext.watch(activeIdFieldName) : undefined + const previewMode = previewOptions?.enabled + ? formContext.watch("previewMode") + : undefined + const onRowClick = useCallback( (id: string) => { - if (!activeIdFieldName) return - formContext.setValue(activeIdFieldName!, id) + if (previewOptions?.enabled) { + handleActivePreviewIdChange(id) + return + } + if (activeIdFieldName) { + formContext.setValue(activeIdFieldName!, id) + } }, // eslint-disable-next-line react-hooks/exhaustive-deps [activeIdFieldName] @@ -82,6 +97,52 @@ export const Table: APIFC & { } }, []) + const scrollToPreviewActiveItem = useCallback(() => { + const activeElement = scrollWrapperRef.current?.querySelector( + `tr[data-item-id="${activePreviewId}"]` + ) + if (activeElement) { + activeElement.scrollIntoView({ + block: "nearest", + }) + } + }, [activePreviewId]) + + const handleActivePreviewIdChange = useCallback( + (id?: string) => { + setActivePreviewId(id) + if (id) { + nextActivePreviewId.current = data[(data.indexOf(id) + 1) % data.length] + } + }, + [data] + ) + + useEffect(() => { + if (activePreviewId !== undefined) { + scrollToPreviewActiveItem() + formContext.setValue("previewMode", true) + } else if (previewMode) { + formContext.setValue("previewMode", false) + nextActivePreviewId.current = undefined + if (formOptions.selectedIdsFieldName) { + formContext.setValue(formOptions.selectedIdsFieldName, []) + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + activePreviewId, + formOptions.selectedIdsFieldName, + previewMode, + scrollToPreviewActiveItem, + ]) + + useEffect(() => { + if (activePreviewId && !data.includes(activePreviewId)) { + handleActivePreviewIdChange(nextActivePreviewId.current) + } + }, [activePreviewId, data, handleActivePreviewIdChange]) + useEffect(() => { if (activeRowId) { scrollToActiveItem() @@ -144,6 +205,7 @@ export const Table: APIFC & { return ( @@ -200,6 +262,7 @@ export const Table: APIFC & { return (
& { ] ) + const preview = useMemo(() => { + if (!previewOptions || !previewOptions.enabled) return null + + return ( + + ) + }, [activePreviewId, data, handleActivePreviewIdChange, previewOptions]) + return useMemo( () => ( - - - -
- {renderHeaderChildren()} -
-
- - {data?.map((id, index) => renderRow(id, index))} - -
-
+ <> + + + +
+ {renderHeaderChildren()} +
+
+ + {data?.map((id, index) => renderRow(id, index))} + +
+
+ {preview} + ), - [data, isClickable, props, renderRow, renderHeaderChildren] + [ + props, + isClickable, + previewOptions?.enabled, + renderHeaderChildren, + data, + preview, + renderRow, + ] ) } @@ -256,12 +353,22 @@ const TableWrapper = styled.table` border-spacing: 0; ` -const TableHeader = styled.thead` +const TableHeader = styled.thead<{ $hasClickableRows?: boolean }>` position: sticky; z-index: 2; top: 0; background: #fff; + tr { + ${({ $hasClickableRows }) => + $hasClickableRows && + css` + &:before { + content: ""; + } + `}; + } + th { text-align: left; white-space: nowrap; diff --git a/libs/generic-view/utils/src/lib/models/icons.types.ts b/libs/generic-view/utils/src/lib/models/icons.types.ts index 086982696..3b4bd697a 100644 --- a/libs/generic-view/utils/src/lib/models/icons.types.ts +++ b/libs/generic-view/utils/src/lib/models/icons.types.ts @@ -4,6 +4,8 @@ */ export enum IconType { + ArrowLeft = "arrow-left", + ArrowRight = "arrow-right", Battery0 = "battery-0", Battery1 = "battery-1", Battery2 = "battery-2", diff --git a/libs/shared/utils/src/index.ts b/libs/shared/utils/src/index.ts index 86f65ae73..577c645a2 100644 --- a/libs/shared/utils/src/index.ts +++ b/libs/shared/utils/src/index.ts @@ -23,3 +23,4 @@ export * from "./lib/exec-command-with-sudo" export * from "./lib/use-filtered-routes-history" export * from "./lib/slice-segments" export * from "./lib/use-dev-console-mock" +export * from "./lib/queue/queue" diff --git a/libs/shared/utils/src/lib/queue/queue.test.ts b/libs/shared/utils/src/lib/queue/queue.test.ts new file mode 100644 index 000000000..ac0ae4b19 --- /dev/null +++ b/libs/shared/utils/src/lib/queue/queue.test.ts @@ -0,0 +1,346 @@ +/** + * Copyright (c) Mudita sp. z o.o. All rights reserved. + * For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md + */ + +import { Queue } from "./queue" + +const delayTask = () => new Promise((resolve) => setTimeout(resolve, 100)) + +describe("Queue E2E Tests", () => { + let queue: Queue + + beforeEach(() => { + queue = new Queue({ interval: 100 }) + }) + + it("should process items in the order they were added", async () => { + const results: string[] = [] + + const p1 = queue.add({ + id: "task1", + task: async () => { + await delayTask() + results.push("task1") + }, + }) + + const p2 = queue.add({ + id: "task2", + task: async () => { + await delayTask() + results.push("task2") + }, + }) + + const p3 = queue.add({ + id: "task3", + task: async () => { + await delayTask() + results.push("task3") + }, + }) + + await Promise.all([p1, p2, p3]) + expect(results).toEqual(["task1", "task2", "task3"]) + + const p4 = queue.add({ + id: "task4", + task: async () => { + await delayTask() + results.push("task4") + }, + }) + + await p4 + expect(results).toEqual(["task1", "task2", "task3", "task4"]) + }) + + it("should process items in the order of priorities", async () => { + const results: string[] = [] + + const p1 = queue.add({ + id: "task1", + task: async () => { + await delayTask() + results.push("task1") + }, + }) + + const p2 = queue.add({ + id: "task2", + task: async () => { + await delayTask() + results.push("task2") + }, + }) + + const p3 = queue.add({ + id: "task3", + task: async () => { + await delayTask() + results.push("task3") + }, + priority: 2, + }) + + await Promise.all([p1, p2, p3]) + + const p4 = queue.add({ + id: "task4", + task: async () => { + await delayTask() + results.push("task4") + }, + priority: 3, + }) + + await p4 + + expect(results).toEqual(["task3", "task1", "task2", "task4"]) + }) + + it("should override only item's priority when adding an item with same id", async () => { + const results: string[] = [] + + const p1 = queue.add({ + id: "task1", + task: async () => { + await delayTask() + results.push("task1") + }, + }) + + const p2 = queue.add({ + id: "task2", + task: async () => { + await delayTask() + results.push("task2") + }, + priority: 2, + }) + + const p3 = queue.add({ + id: "task3", + task: async () => { + await delayTask() + results.push("task3") + }, + priority: 1, + }) + + const p4 = queue.add({ + id: "task3", + task: async () => { + await delayTask() + results.push("task3 (updated)") + }, + priority: 3, + }) + + const [_r1, _r2, r3, r4] = await Promise.all([p1, p2, p3, p4]) + + expect(results).toEqual(["task3", "task2", "task1"]) + expect(r3.priority).toEqual(3) + expect(r4.priority).toEqual(3) + }) + + it("should continue processing after certain task failure", async () => { + const results: string[] = [] + + const p1 = queue.add({ + id: "task1", + task: async () => { + await delayTask() + results.push("task1") + }, + }) + + const p2 = queue.add({ + id: "task2", + task: async () => { + await delayTask() + results.push("task2") + }, + }) + + const p3 = queue.add({ + id: "task3", + task: async () => { + await Promise.reject("Failure in task3") + results.push("task3") + }, + }) + + const p4 = queue.add({ + id: "task4", + task: async () => { + await delayTask() + results.push("task4") + }, + }) + + const [_r1, _r2, r3] = await Promise.all([p1, p2, p3, p4]) + + expect(results).toEqual(["task1", "task2", "task4"]) + expect(r3.reason).toEqual(new Error("Failure in task3")) + }) + + it("should handle removed items correctly", async () => { + const results: string[] = [] + + const p1 = queue.add({ + id: "task1", + task: async () => { + await delayTask() + results.push("task1") + }, + }) + + const p2 = queue.add({ + id: "task2", + task: async () => { + await delayTask() + results.push("task2") + }, + }) + + const p3 = queue.add({ + id: "task3", + task: async () => { + await delayTask() + results.push("task3") + }, + }) + + const p4 = queue.add({ + id: "task4", + task: async () => { + await delayTask() + results.push("task4") + }, + }) + + queue.remove("task1") + queue.remove("task3") + + const p5 = queue.add({ + id: "task5", + task: async () => { + await delayTask() + results.push("task5") + }, + }) + + const [r1, _r2, r3] = await Promise.all([p1, p2, p3, p4, p5]) + + expect(results).toEqual(["task2", "task4", "task5"]) + expect(r1.reason).toEqual("removed") + expect(r3.reason).toEqual("removed") + }) + + it("should clear the queue after calling the `clear` method and finish processing tasks", async () => { + const results: string[] = [] + + const p1 = queue.add({ + id: "task1", + task: async () => { + await delayTask() + results.push("task1") + }, + }) + + const p2 = queue.add({ + id: "task2", + task: async () => { + await delayTask() + results.push("task2") + }, + }) + + queue.clear() + + const [r1, r2] = await Promise.all([p1, p2]) + expect(results).toEqual([]) + expect(r1.reason).toEqual("removed") + expect(r2.reason).toEqual("removed") + }) + + it("should handle custom interval properly", async () => { + const results: string[] = [] + const customQueue = new Queue({ interval: 1000 }) + + const timestamps: number[] = [] + + const p1 = customQueue.add({ + id: "task1", + task: async () => { + await delayTask() + results.push("task1") + timestamps.push(performance.now()) + }, + }) + const p2 = customQueue.add({ + id: "task2", + task: async () => { + timestamps.push(performance.now()) + await delayTask() + results.push("task2") + }, + }) + + await Promise.all([p1, p2]) + expect(results).toEqual(["task1", "task2"]) + expect(timestamps[1] - timestamps[0]).toBeGreaterThanOrEqual(999) + }) + + it("should enforce capacity correctly", async () => { + const results: string[] = [] + const capQueue = new Queue({ interval: 0, capacity: 3 }) + + const p1 = capQueue.add({ + id: "task1", + task: async () => { + results.push("task1") + }, + priority: 1, + }) + const p2 = capQueue.add({ + id: "task2", + task: async () => { + results.push("task2") + }, + priority: 2, + }) + const p3 = capQueue.add({ + id: "task3", + task: async () => { + results.push("task3") + }, + priority: 3, + }) + const p4 = capQueue.add({ + id: "task4", + task: async () => { + results.push("task4") + }, + priority: 4, + }) + const p5 = capQueue.add({ + id: "task5", + task: async () => { + results.push("task5") + }, + priority: 5, + }) + + const [r1, r2, r3, r4, r5] = await Promise.all([p1, p2, p3, p4, p5]) + expect(results).toEqual(["task5", "task4", "task3"]) + expect(r1.reason).toEqual("removed") + expect(r2.reason).toEqual("removed") + expect(r3.reason).toBeUndefined() + expect(r3.priority).toEqual(3) + expect(r4.priority).toEqual(4) + expect(r5.priority).toEqual(5) + }) +}) diff --git a/libs/shared/utils/src/lib/queue/queue.ts b/libs/shared/utils/src/lib/queue/queue.ts new file mode 100644 index 000000000..dd77f1c18 --- /dev/null +++ b/libs/shared/utils/src/lib/queue/queue.ts @@ -0,0 +1,179 @@ +/** + * Copyright (c) Mudita sp. z o.o. All rights reserved. + * For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md + */ + +import { EventEmitter } from "events" + +enum ItemEvent { + Finished = "finished", +} + +interface Item { + id: string + task: (task: Omit) => Promise + priority: number + abortController: AbortController +} + +type ResponseItem = Omit & { + reason?: "removed" | "aborted" | Error +} + +/** + * Queue class that manages a list of tasks to be processed in order of priority. + * It allows adding, removing, and processing tasks with a specified interval. + * Each task can be aborted, and the queue can handle a maximum capacity. + */ +export class Queue { + private queue: Item[] = [] + private eventEmitter = new EventEmitter() + private currentItem?: Item + private processing = false + private scheduledStart = false + private readonly interval: number + private readonly capacity?: number + + /** + * Creates a new Queue instance. + * @param options - Configuration options for the queue. + * @param options.interval - Time in milliseconds to wait between processing next tasks. Defaults to 0. + * @param options.capacity - Maximum number of items in the queue. If exceeded, the lowest-priority items will be aborted and removed. Defaults to no limit. + */ + constructor( + options: { + interval?: number + capacity?: number + } = {} + ) { + this.interval = options.interval || 0 + this.capacity = options.capacity + } + + private async processQueue() { + this.processing = true + while (this.queue.length > 0) { + const item = this.queue.shift()! + this.currentItem = item + const { signal } = item.abortController + await new Promise((resolve) => setTimeout(resolve, 0)) + if (signal.aborted) continue + try { + await item.task({ id: item.id, priority: item.priority }) + this.eventEmitter.emit(ItemEvent.Finished, item.id) + } catch (error) { + item.abortController.abort( + error instanceof Error ? error : new Error(String(error)) + ) + } + await new Promise((resolve) => { + return setTimeout(resolve, this.interval) + }) + } + this.currentItem = undefined + this.processing = false + } + + /** + * Adds a new item to the queue or updates an existing one. + * If the item already exists, it updates its priority and reorders the queue. + * @param newItem - The item to add or update in the queue. + * @param newItem.id - Unique identifier for the item. + * @param newItem.task - The task function to be executed, which should return a promise. + * @param newItem.priority - The priority of the item, where higher values indicate higher priority. Defaults to 0 if not provided. + * @returns A promise that resolves to an object containing the item's id, task, priority, and any reason for abortion if applicable. + */ + async add( + newItem: Pick & Partial> + ) { + const existingItem = this.queue.find((i) => i.id === newItem.id) + const abortController = + existingItem?.abortController || new AbortController() + + if (existingItem) { + existingItem.priority = newItem.priority ?? existingItem.priority + this.queue.sort((a, b) => b.priority - a.priority) + return { + id: existingItem.id, + task: existingItem.task, + priority: existingItem.priority, + } + } + const item: Item = { + id: newItem.id, + task: async (props) => { + await newItem.task(props) + }, + priority: newItem.priority ?? 0, + abortController, + } + this.queue.push(item) + + this.queue.sort((a, b) => b.priority - a.priority) + if (this.capacity !== undefined) { + const overflow = this.queue.length - this.capacity + for (let i = 0; i < overflow; i++) { + const removed = this.queue.pop()! + removed.abortController.abort("removed") + } + } + if (!this.processing && !this.scheduledStart) { + this.scheduledStart = true + setTimeout(() => { + this.scheduledStart = false + this.processQueue() + }, 0) + } + + return new Promise((resolve) => { + abortController.signal.addEventListener( + "abort", + () => { + const { id, task, priority } = item + resolve({ id, task, priority, reason: abortController.signal.reason }) + }, + { once: true } + ) + const onFinished = (finishedId: Item["id"]) => { + if (finishedId === item.id) { + this.eventEmitter.off(ItemEvent.Finished, onFinished) + const { id, task, priority } = item + resolve({ id, task, priority }) + } + } + this.eventEmitter.on(ItemEvent.Finished, onFinished) + }) + } + + /** + * Removes an item from the queue by its ID. + * If the item is currently being processed, it will be aborted. + * @param itemId + */ + remove(itemId: Item["id"]) { + if (this.currentItem?.id === itemId) { + this.currentItem.abortController.abort("removed") + } else { + this.queue = this.queue.filter((item) => { + if (item.id === itemId) { + item.abortController.abort("removed") + return false + } + return item.id !== itemId + }) + } + } + + /** + * Clears the queue and aborts all currently processing items. + * This will stop any ongoing tasks and remove all items from the queue. + */ + clear() { + for (const item of [this.currentItem, ...this.queue]) { + item?.abortController.abort("removed") + } + this.queue = [] + this.currentItem = undefined + this.processing = false + } +} diff --git a/libs/system-utils/feature/src/lib/directory/directory.service.ts b/libs/system-utils/feature/src/lib/directory/directory.service.ts index dfc03fd41..7b3cd285c 100644 --- a/libs/system-utils/feature/src/lib/directory/directory.service.ts +++ b/libs/system-utils/feature/src/lib/directory/directory.service.ts @@ -22,6 +22,7 @@ const appPathType = { pureBackups: ["pure", "phone", "backups"], pureBackupTemp: ["pure", "phone", "backup-temp"], helpV2: ["help-v2.json"], + filePreview: ["file-preview"], } as const export type AppPathType = keyof typeof appPathType diff --git a/package-lock.json b/package-lock.json index d4b941b00..4114543a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "crypto-js": "^4.2.0", "js-crc": "^0.2.0", "jschardet": "^3.1.2", + "motion": "^12.23.10", "node-ipc": "10.1.0", "papaparse": "^5.4.1", "react-is": "18.2.0", @@ -2975,7 +2976,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.2.1.tgz", "integrity": "sha512-61Mf7Ufx4aDxx1xlDeOm8aFFigGHE4z+0sKCa+IHCeZKiyP9RLD0Mmx7m8b9/Cf37f7NAvQOOJAbQQGVr5uERw==", - "dev": true, + "devOptional": true, "dependencies": { "@emotion/memoize": "^0.8.1" } @@ -2984,7 +2985,7 @@ "version": "0.8.1", "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.1.tgz", "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==", - "dev": true + "devOptional": true }, "node_modules/@emotion/react": { "version": "11.11.1", @@ -15778,10 +15779,13 @@ } }, "node_modules/available-typed-arrays": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", - "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", "dev": true, + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, "engines": { "node": ">= 0.4" }, @@ -18877,16 +18881,15 @@ } }, "node_modules/call-bind": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", - "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", "dev": true, "dependencies": { + "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.1" + "set-function-length": "^1.2.2" }, "engines": { "node": ">= 0.4" @@ -18908,6 +18911,22 @@ "node": ">= 0.4" } }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -25749,12 +25768,18 @@ } }, "node_modules/for-each": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", - "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", "dev": true, "dependencies": { - "is-callable": "^1.1.3" + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/for-in": { @@ -25950,6 +25975,32 @@ "node": ">=0.10.0" } }, + "node_modules/framer-motion": { + "version": "12.23.10", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.10.tgz", + "integrity": "sha512-ziXHr+C91FhgdSV65YA9SNbLy7uIDQ0pq7pEWlMP6Bh9UJAHFUNUvKWzE41g1B7YuvgJtUUNLgNmZyHd/YQ2gA==", + "dependencies": { + "motion-dom": "^12.23.9", + "motion-utils": "^12.23.6", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -29056,12 +29107,12 @@ } }, "node_modules/is-typed-array": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.12.tgz", - "integrity": "sha512-Z14TF2JNG8Lss5/HMqt0//T9JeHXttXy5pH/DBU4vi98ozO2btxzq9MwYDZYnKwU8nRsz/+GVFVRDq3DkVuSPg==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", "dev": true, "dependencies": { - "which-typed-array": "^1.1.11" + "which-typed-array": "^1.1.16" }, "engines": { "node": ">= 0.4" @@ -36136,6 +36187,44 @@ "node": "*" } }, + "node_modules/motion": { + "version": "12.23.10", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.23.10.tgz", + "integrity": "sha512-pE3WsRXbRjVQaB2vCmVLt8gYiZdX11KYIoWblc49JnG2dTn6oHobIw+bORL31ZfiYBZD8BYmqkvEZp+HLPBBVw==", + "dependencies": { + "framer-motion": "^12.23.10", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/motion-dom": { + "version": "12.23.9", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.9.tgz", + "integrity": "sha512-6Sv++iWS8XMFCgU1qwKj9l4xuC47Hp4+2jvPfyTXkqDg2tTzSgX6nWKD4kNFXk0k7llO59LZTPuJigza4A2K1A==", + "dependencies": { + "motion-utils": "^12.23.6" + } + }, + "node_modules/motion-utils": { + "version": "12.23.6", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz", + "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==" + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -38262,6 +38351,15 @@ "node": ">=0.10.0" } }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/postcss": { "version": "8.4.32", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.32.tgz", @@ -39493,7 +39591,7 @@ "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", - "dev": true, + "devOptional": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.0" @@ -41741,7 +41839,7 @@ "version": "0.23.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==", - "dev": true, + "devOptional": true, "dependencies": { "loose-envify": "^1.1.0" } @@ -45070,14 +45168,14 @@ } }, "node_modules/typed-array-buffer": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.0.tgz", - "integrity": "sha512-Y8KTSIglk9OZEr8zywiIHG/kmQ7KWyjseXs1CbSo8vC42w7hg2HgYTxSWwP0+is7bWDc1H+Fo026CpHFwm8tkw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.2.1", - "is-typed-array": "^1.1.10" + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" }, "engines": { "node": ">= 0.4" @@ -47258,16 +47356,18 @@ "peer": true }, "node_modules/which-typed-array": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.13.tgz", - "integrity": "sha512-P5Nra0qjSncduVPEAr7xhoF5guty49ArDTwzJ/yNuPIbZppyRxFQsRCWrocxIY+CnMVG+qfbU2FmDKyvSGClow==", + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", "dev": true, "dependencies": { - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.4", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-tostringtag": "^1.0.0" + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" }, "engines": { "node": ">= 0.4" diff --git a/package.json b/package.json index eaad7a73a..c810f284c 100644 --- a/package.json +++ b/package.json @@ -290,6 +290,7 @@ "crypto-js": "^4.2.0", "js-crc": "^0.2.0", "jschardet": "^3.1.2", + "motion": "^12.23.10", "node-ipc": "10.1.0", "papaparse": "^5.4.1", "react-is": "18.2.0",