diff --git a/apps/frontend/src/__setup__/__mocks__/zustand.ts b/apps/frontend/src/__setup__/__mocks__/zustand.ts index 3ff74580..8071ad30 100644 --- a/apps/frontend/src/__setup__/__mocks__/zustand.ts +++ b/apps/frontend/src/__setup__/__mocks__/zustand.ts @@ -20,7 +20,10 @@ const createUncurried = (stateCreator: zustand.StateCreator) => { return store } -export const create = ((stateCreator: zustand.StateCreator) => typeof stateCreator === 'function' ? createUncurried(stateCreator) : createUncurried) as typeof zustand.create +export const create = ((stateCreator: zustand.StateCreator) => + typeof stateCreator === 'function' + ? createUncurried(stateCreator) + : createUncurried) as typeof zustand.create const createStoreUncurried = (stateCreator: zustand.StateCreator) => { const store = actualCreateStore(stateCreator) @@ -33,9 +36,10 @@ const createStoreUncurried = (stateCreator: zustand.StateCreator) => { return store } -export const createStore = ((stateCreator: zustand.StateCreator) => typeof stateCreator === 'function' - ? createStoreUncurried(stateCreator) - : createStoreUncurried) as typeof zustand.createStore +export const createStore = ((stateCreator: zustand.StateCreator) => + typeof stateCreator === 'function' + ? createStoreUncurried(stateCreator) + : createStoreUncurried) as typeof zustand.createStore afterEach(() => { act(() => { diff --git a/apps/frontend/src/api/convert-image.ts b/apps/frontend/src/api/convert-image.ts index c547dd2b..53d71232 100644 --- a/apps/frontend/src/api/convert-image.ts +++ b/apps/frontend/src/api/convert-image.ts @@ -55,10 +55,14 @@ async function convertImageMutation({ const imageBlob = await convertImage(formData) const link = URL.createObjectURL(imageBlob) + const newFile = new File([imageBlob], fileName, { + type: settings.outputFormat ? `image/${settings.outputFormat}` : file.type + }) + return { link, fileName, - blob: imageBlob + file: newFile } } diff --git a/apps/frontend/src/api/handle-image-metadata.ts b/apps/frontend/src/api/handle-image-metadata.ts index df3fefa0..783ffcf3 100644 --- a/apps/frontend/src/api/handle-image-metadata.ts +++ b/apps/frontend/src/api/handle-image-metadata.ts @@ -55,10 +55,14 @@ async function handleImageMetadataMutation({ const imageBlob = await handleImageMetadata(formData) const link = URL.createObjectURL(imageBlob) + const newFile = new File([imageBlob], fileName, { + type: file.type + }) + return { link, fileName, - blob: imageBlob + file: newFile } } diff --git a/apps/frontend/src/api/resize-image.ts b/apps/frontend/src/api/resize-image.ts index 00fb01ee..81db8b91 100644 --- a/apps/frontend/src/api/resize-image.ts +++ b/apps/frontend/src/api/resize-image.ts @@ -9,7 +9,6 @@ import { FetchException } from './exceptions/FetchException' import { createApiURL } from '@site/config' import { PATH_API_RESIZE } from '@site/paths' import { ABORT_TIMEOUT } from './config' -import { addImageSizesToFileName } from '@helpers/file/addImageSizesToFileName' import type { MutationPayload } from './types' async function resizeImage(formData: FormData): Promise { @@ -56,21 +55,26 @@ async function resizeImageMutation({ const imageBlob = await resizeImage(formData) const link = URL.createObjectURL(imageBlob) - let outputFileName = fileName + const outputFileName = fileName - const { resize } = settings - if (resize && resize.width && resize.height) { - outputFileName = addImageSizesToFileName({ - fullFileName: outputFileName, - width: resize.width, - height: resize.height - }) - } + // TODO: rework + // const { resize } = settings + // if (resize && resize.width && resize.height) { + // outputFileName = addImageSizesToFileName({ + // fullFileName: outputFileName, + // width: resize.width, + // height: resize.height + // }) + // } + + const newFile = new File([imageBlob], outputFileName, { + type: file.type + }) return { link, fileName: outputFileName, - blob: imageBlob + file: newFile } } diff --git a/apps/frontend/src/components/CheckboxKeepChanges.tsx b/apps/frontend/src/components/CheckboxKeepChanges.tsx new file mode 100644 index 00000000..b9dde25d --- /dev/null +++ b/apps/frontend/src/components/CheckboxKeepChanges.tsx @@ -0,0 +1,11 @@ +import { OptionCheckbox } from '@components/OptionCheckbox' +import { useOutputStore } from '@stores/output' + +export const CheckboxKeepChanges = () => { + const checked = useOutputStore(state => state.keepChanges) + const toggleKeepChanges = useOutputStore(state => state.toggleKeepChanges) + + const handleToggle = () => toggleKeepChanges() + + return +} diff --git a/apps/frontend/src/stores/__tests__/output.test.ts b/apps/frontend/src/stores/__tests__/output.test.ts index b3da8ed0..281d88b5 100644 --- a/apps/frontend/src/stores/__tests__/output.test.ts +++ b/apps/frontend/src/stores/__tests__/output.test.ts @@ -3,9 +3,12 @@ import { IMAGE_FILE_FORMAT } from '@scissors/sharp' import { createOutputStore, defaultState, type DownloadPayload } from '@stores/output' +import pick from 'lodash.pick' describe('@stores/output', () => { - const store = createOutputStore() + const store = createOutputStore({ + withPersist: false + }) afterEach(() => { store.setState(defaultState) @@ -17,9 +20,11 @@ describe('@stores/output', () => { }) expect(store.getState().file).toBe(null) + expect(store.getState().originalFile).toBe(null) expect(store.getState().isFileUploaded()).toBeFalsy() store.getState().setFile(file) expect(store.getState().file).toStrictEqual(file) + expect(store.getState().originalFile).toStrictEqual(file) expect(store.getState().isFileUploaded()).toBeTruthy() store.getState().removeFile() @@ -43,16 +48,30 @@ describe('@stores/output', () => { }) it('should correctly set download payload', () => { + const file1 = new File([], 'foo.png', { type: 'image/png' }) + const file2 = new File([], 'bar.webp', { type: 'image/webp' }) const testValue: DownloadPayload = { - blob: new Blob(), - fileName: 'foo-bar-baz', - link: 'blob:foo-bar-baz' + file: file2, + fileName: 'foo.png', + link: 'blob:foo.png' } expect(store.getState().downloadPayload).toBe(defaultState.downloadPayload) + expect(store.getState().originalFile).toBe(null) + + store.setState({ + file: file1, + originalFile: file1, + keepChanges: true + }) + + expect(store.getState().getFileForProcessing()).toStrictEqual(file1) store.getState().setDownloadPayload(testValue) - expect(store.getState().downloadPayload).toStrictEqual(testValue) + expect(store.getState().downloadPayload).toStrictEqual(pick(testValue, ['fileName', 'link'])) + expect(store.getState().file).toStrictEqual(file2) + expect(store.getState().originalFile).toStrictEqual(file1) + expect(store.getState().getFileForProcessing()).toStrictEqual(file2) }) suite('should correctly set output file format', () => { diff --git a/apps/frontend/src/stores/output.ts b/apps/frontend/src/stores/output.ts index 25e4c531..fb565431 100644 --- a/apps/frontend/src/stores/output.ts +++ b/apps/frontend/src/stores/output.ts @@ -1,18 +1,21 @@ -import { create, type StateCreator } from 'zustand' +import { create, type StateCreator, type StoreApi, type UseBoundStore } from 'zustand' +import { persist } from 'zustand/middleware' + +import type { ImageFileFormat } from '@scissors/sharp' import { isValidFileName } from '@helpers/file/isValidFileName' import { cropFileName } from '@helpers/file/cropFileName' import { cropImageFileType } from '@helpers/file/cropImageFileType' -import type { ImageFileFormat } from '@scissors/sharp' export interface DownloadPayload { link: string fileName: string - blob: Blob + file: File } /* eslint no-unused-vars: 0 */ interface Store extends State { + getFileForProcessing: () => File | null getOutputFormat: () => ImageFileFormat | null getFullFileName: () => string isFileUploaded: () => boolean @@ -24,20 +27,25 @@ interface Store extends State { resetOutputFileName: VoidFunction setOutputFormat: (outputFormat: ImageFileFormat | null) => void setDownloadPayload: (downloadPayload: DownloadPayload) => void + toggleKeepChanges: VoidFunction } interface State { file: File | null + originalFile: File | null outputFileName: string outputFormat: ImageFileFormat | null - downloadPayload: DownloadPayload | null + downloadPayload: Omit | null + keepChanges: boolean } export const defaultState: State = { file: null, + originalFile: null, outputFileName: '', outputFormat: null, - downloadPayload: null + downloadPayload: null, + keepChanges: false } as const const outputStoreCreator: StateCreator = (set, get) => ({ @@ -45,6 +53,11 @@ const outputStoreCreator: StateCreator = (set, get) => ({ ...defaultState, // Computed + getFileForProcessing: () => { + const { file, originalFile, keepChanges } = get() + + return keepChanges ? file : originalFile + }, getFullFileName: () => { const file = get().file @@ -102,6 +115,7 @@ const outputStoreCreator: StateCreator = (set, get) => ({ set({ file, + originalFile: file, outputFormat, downloadPayload: null }) @@ -109,17 +123,65 @@ const outputStoreCreator: StateCreator = (set, get) => ({ removeFile: () => set({ file: null, + originalFile: null, outputFormat: null, downloadPayload: null }), - setOutputFileName: (outputFileName: string) => set({ outputFileName }), - resetOutputFileName: () => set({ outputFileName: defaultState.outputFileName }), + setOutputFileName: outputFileName => + set({ + outputFileName + }), + resetOutputFileName: () => + set({ + outputFileName: defaultState.outputFileName + }), setOutputFormat: outputFormat => set({ outputFormat }), - setDownloadPayload: downloadPayload => set({ downloadPayload }) + setDownloadPayload: ({ file, ...downloadPayload }) => + set({ + file, + downloadPayload + }), + + toggleKeepChanges: () => + set({ + keepChanges: !get().keepChanges + }) }) -export const createOutputStore = () => create()(outputStoreCreator) +export const createOutputStore = ({ + withPersist +}: CreateStoreParams): UseBoundStore> => { + if (withPersist) { + return create( + persist(outputStoreCreator, { + name: 'scissors-output-store', + merge: (persistedState: unknown, currentState: State): State => { + if (!persistedState || typeof persistedState !== 'object') { + return currentState + } + + if ('keepChanges' in persistedState && typeof persistedState.keepChanges === 'boolean') { + return { + ...currentState, + keepChanges: persistedState.keepChanges + } + } + + return currentState + } + }) + ) + } + + return create()(outputStoreCreator) +} + +interface CreateStoreParams { + withPersist: boolean +} -export const useOutputStore = createOutputStore() +export const useOutputStore = createOutputStore({ + withPersist: true +}) diff --git a/apps/frontend/src/widgets/AppFooter/AppFooterContent/AppFooterContent.tsx b/apps/frontend/src/widgets/AppFooter/AppFooterContent/AppFooterContent.tsx index 6751536b..b0fe3a1a 100644 --- a/apps/frontend/src/widgets/AppFooter/AppFooterContent/AppFooterContent.tsx +++ b/apps/frontend/src/widgets/AppFooter/AppFooterContent/AppFooterContent.tsx @@ -29,7 +29,9 @@ export default function AppFooterContent() { return ( + + {selectedTab === TOOLBAR_TAB.CONVERT && } {selectedTab === TOOLBAR_TAB.RESIZE && } {selectedTab === TOOLBAR_TAB.METADATA && } diff --git a/apps/frontend/src/widgets/AppFooter/AppFooterContent/ButtonConvert.tsx b/apps/frontend/src/widgets/AppFooter/AppFooterContent/ButtonConvert.tsx index e7c4dd4f..3b7e2f91 100644 --- a/apps/frontend/src/widgets/AppFooter/AppFooterContent/ButtonConvert.tsx +++ b/apps/frontend/src/widgets/AppFooter/AppFooterContent/ButtonConvert.tsx @@ -7,7 +7,7 @@ import { useOutputStore } from '@stores/output' import { useRequestStore } from '@stores/request' export function ButtonConvert() { - const file = useOutputStore(state => state.file) + const file = useOutputStore(state => state.getFileForProcessing()) const fileName = useOutputStore(state => state.getFullFileName()) const isLoading = useRequestStore(state => state.isLoading) const setLoading = useRequestStore(state => state.setLoading) diff --git a/apps/frontend/src/widgets/AppFooter/AppFooterContent/ButtonDownload.tsx b/apps/frontend/src/widgets/AppFooter/AppFooterContent/ButtonDownload.tsx index 5d8dd7d6..69bf7f29 100644 --- a/apps/frontend/src/widgets/AppFooter/AppFooterContent/ButtonDownload.tsx +++ b/apps/frontend/src/widgets/AppFooter/AppFooterContent/ButtonDownload.tsx @@ -1,5 +1,5 @@ import { useRef } from 'react' -import { Button, Link, Text } from '@radix-ui/themes' +import { Button, Link as RadixLink } from '@radix-ui/themes' import MediaQuery from 'react-responsive' import { DownloadIcon } from '@scissors/react-icons/DownloadIcon' @@ -11,6 +11,7 @@ export function ButtonDownload() { const linkRef = useRef(null) const downloadPayload = useOutputStore(state => state.downloadPayload) + const disabled = !downloadPayload const handleButtonClick = () => { if (!linkRef.current) return @@ -23,24 +24,24 @@ export function ButtonDownload() { - + {downloadPayload && ( + + )} ) } diff --git a/apps/frontend/src/widgets/AppFooter/AppFooterContent/ButtonMetadata.tsx b/apps/frontend/src/widgets/AppFooter/AppFooterContent/ButtonMetadata.tsx index 8dca89b2..663c9c13 100644 --- a/apps/frontend/src/widgets/AppFooter/AppFooterContent/ButtonMetadata.tsx +++ b/apps/frontend/src/widgets/AppFooter/AppFooterContent/ButtonMetadata.tsx @@ -7,7 +7,7 @@ import { useMetadataStore } from '@stores/metadata' import { useMetadataMutation } from '@api/handle-image-metadata' export function ButtonMetadata() { - const file = useOutputStore(state => state.file) + const file = useOutputStore(state => state.getFileForProcessing()) const fileName = useOutputStore(state => state.getFullFileName()) const isLoading = useRequestStore(state => state.isLoading) const setLoading = useRequestStore(state => state.setLoading) diff --git a/apps/frontend/src/widgets/AppFooter/AppFooterContent/ButtonRequest.module.css b/apps/frontend/src/widgets/AppFooter/AppFooterContent/ButtonRequest.module.css new file mode 100644 index 00000000..b6c9b37f --- /dev/null +++ b/apps/frontend/src/widgets/AppFooter/AppFooterContent/ButtonRequest.module.css @@ -0,0 +1,30 @@ +.root { + --border-radius: var(--radius-4); + + border-radius: var(--border-radius); +} + +.root :global(.rt-SpinnerLeaf) { + width: 2px; +} + +.disabled { + cursor: not-allowed; +} + +.button { + border-radius: var(--border-radius) 0 0 var(--border-radius); +} + +.dropdownTrigger { + width: 34px; + border-radius: 0 var(--border-radius) var(--border-radius) 0; +} + +.dropdownContent { + border-radius: var(--radius-2); +} + +.dropdownContent :global(.rt-DropdownMenuViewport) { + padding: var(--space-3); +} diff --git a/apps/frontend/src/widgets/AppFooter/AppFooterContent/ButtonRequest.tsx b/apps/frontend/src/widgets/AppFooter/AppFooterContent/ButtonRequest.tsx index e3c42063..1751de12 100644 --- a/apps/frontend/src/widgets/AppFooter/AppFooterContent/ButtonRequest.tsx +++ b/apps/frontend/src/widgets/AppFooter/AppFooterContent/ButtonRequest.tsx @@ -1,51 +1,58 @@ -import { type FC, memo } from 'react' import dynamic from 'next/dynamic' -import MediaQuery from 'react-responsive' -import { Button, Spinner, Text } from '@radix-ui/themes' - -import { SymbolIcon } from '@scissors/react-icons/SymbolIcon' -import { LockClosedIcon } from '@scissors/react-icons/LockClosedIcon' +import { type FC, memo } from 'react' +import { Button, IconButton, DropdownMenu, Flex, Spinner } from '@radix-ui/themes' +import { clsx } from 'clsx' +import { CheckboxKeepChanges } from '@components/CheckboxKeepChanges' import { useOutputStore } from '@stores/output' import { TOUR_STEP } from '@lib/tour' +import styles from './ButtonRequest.module.css' const RequestErrorAlert = dynamic( () => import('@components/alerts/RequestErrorAlert').then(mod => mod.RequestErrorAlert), - { ssr: false } + { + ssr: false + } ) export const ButtonRequest: FC = memo( ({ label, isLoading, error, makeRequest, retry, reset, isDisabled }) => { const isFileUploaded = useOutputStore(state => state.isFileUploaded()) - const lowercaseLabel = label.toLowerCase() + const disabled = isDisabled || isLoading || !isFileUploaded return ( <> - - - - {!isFileUploaded ? ( - - ) : ( - - )} - + + + + + + + + - - - - - + + + + + {error && error instanceof Error && ( diff --git a/apps/frontend/src/widgets/AppFooter/AppFooterContent/ButtonResize.tsx b/apps/frontend/src/widgets/AppFooter/AppFooterContent/ButtonResize.tsx index 9c39f8e7..c0fdd70e 100644 --- a/apps/frontend/src/widgets/AppFooter/AppFooterContent/ButtonResize.tsx +++ b/apps/frontend/src/widgets/AppFooter/AppFooterContent/ButtonResize.tsx @@ -7,7 +7,7 @@ import { useOutputStore } from '@stores/output' import { useRequestStore } from '@stores/request' export function ButtonResize() { - const file = useOutputStore(state => state.file) + const file = useOutputStore(state => state.getFileForProcessing()) const fileName = useOutputStore(state => state.getFullFileName()) const isLoading = useRequestStore(state => state.isLoading) const setLoading = useRequestStore(state => state.setLoading) diff --git a/apps/frontend/src/widgets/AppFooter/AppFooterContentSkeleton/AppFooterContentSkeleton.module.css b/apps/frontend/src/widgets/AppFooter/AppFooterContentSkeleton/AppFooterContentSkeleton.module.css index 9b5e7522..fd942714 100644 --- a/apps/frontend/src/widgets/AppFooter/AppFooterContentSkeleton/AppFooterContentSkeleton.module.css +++ b/apps/frontend/src/widgets/AppFooter/AppFooterContentSkeleton/AppFooterContentSkeleton.module.css @@ -3,7 +3,7 @@ } .buttonRequest { - width: 124px; + width: 128px; } .buttonDownload, diff --git a/apps/frontend/src/widgets/Preview/UploadedFile/UploadedFileLightbox.tsx b/apps/frontend/src/widgets/Preview/UploadedFile/UploadedFileLightbox.tsx index af927023..c2910660 100644 --- a/apps/frontend/src/widgets/Preview/UploadedFile/UploadedFileLightbox.tsx +++ b/apps/frontend/src/widgets/Preview/UploadedFile/UploadedFileLightbox.tsx @@ -13,11 +13,11 @@ import { Cross1Icon } from '@scissors/react-icons/Cross1Icon' import { type DownloadPayload, useOutputStore } from '@stores/output' function getLightboxProps({ - downloadPayload, - file + file, + downloadPayload }: { - downloadPayload: DownloadPayload | null file: File + downloadPayload: Omit | null }): LightboxExternalProps { const plugins: Plugin[] = [Zoom, Fullscreen] const slides: Slide[] = [] diff --git a/apps/frontend/src/widgets/SettingsPanel/TabMetadata/TabMetadataContent.tsx b/apps/frontend/src/widgets/SettingsPanel/TabMetadata/TabMetadataContent.tsx index 793d1d60..b3beac99 100644 --- a/apps/frontend/src/widgets/SettingsPanel/TabMetadata/TabMetadataContent.tsx +++ b/apps/frontend/src/widgets/SettingsPanel/TabMetadata/TabMetadataContent.tsx @@ -35,10 +35,8 @@ export function TabMetadataContent() { if (!file) return import('exifr').then(({ default: exifr }) => { - const data: File | Blob = downloadPayload ? downloadPayload.blob : file - exifr - .parse(data, { + .parse(file, { mergeOutput: false }) .then(result => setParsedMetadata(result ?? null)) diff --git a/apps/frontend/src/widgets/SettingsPanel/TabResize/sections/extract/ExtractContent/ExtractContent.tsx b/apps/frontend/src/widgets/SettingsPanel/TabResize/sections/extract/ExtractContent/ExtractContent.tsx index 6d3c6828..a2c0f62b 100644 --- a/apps/frontend/src/widgets/SettingsPanel/TabResize/sections/extract/ExtractContent/ExtractContent.tsx +++ b/apps/frontend/src/widgets/SettingsPanel/TabResize/sections/extract/ExtractContent/ExtractContent.tsx @@ -1,4 +1,5 @@ import dynamic from 'next/dynamic' +import { useEffect } from 'react' import { Flex } from '@radix-ui/themes' import { ExtractCallout } from './ExtractCallout' @@ -14,9 +15,14 @@ const ExtractSectionDialog = dynamic( ) export function ExtractContent() { - const file = useOutputStore(state => state.file) + const file = useOutputStore(state => state.getFileForProcessing()) const previewFile = useExtractStore(state => state.previewFile) const previewAspectRatio = useExtractStore(state => state.previewAspectRatio) + const setPreviewFile = useExtractStore(state => state.setPreviewFile) + + useEffect(() => { + setPreviewFile(null) + }, [file, setPreviewFile]) return ( diff --git a/apps/frontend/src/widgets/SettingsPanel/TabResize/sections/extract/ExtractContent/ExtractSectionDialog.tsx b/apps/frontend/src/widgets/SettingsPanel/TabResize/sections/extract/ExtractContent/ExtractSectionDialog.tsx index 7d38f650..050127eb 100644 --- a/apps/frontend/src/widgets/SettingsPanel/TabResize/sections/extract/ExtractContent/ExtractSectionDialog.tsx +++ b/apps/frontend/src/widgets/SettingsPanel/TabResize/sections/extract/ExtractContent/ExtractSectionDialog.tsx @@ -1,7 +1,8 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import { Button, Dialog, Flex } from '@radix-ui/themes' +import { Button, Dialog, Flex, Separator } from '@radix-ui/themes' import Cropper from 'cropperjs' import 'cropperjs/dist/cropper.css' + import { IMAGE_FILE_FORMAT } from '@scissors/sharp' import { ExtractRatioControl } from './ExtractRatioControl' @@ -12,13 +13,6 @@ import { cropperDataToExtractRegion, extractRegionToCropperData } from './utils' import { aspectRatioList } from './data' import styles from './ExtractSectionDialog.module.css' -Cropper.setDefaults({ - rotatable: false, - scalable: false, - zoomable: false, - movable: false -}) - export const ExtractSectionDialog = ({ file }: Props) => { const imageRef = useRef(null) const [open, setOpen] = useState(false) @@ -31,7 +25,6 @@ export const ExtractSectionDialog = ({ file }: Props) => { const setPreviewFile = useExtractStore(state => state.setPreviewFile) const setPreviewAspectRatio = useExtractStore(state => state.setPreviewAspectRatio) const setCropperAspectRatio = useExtractStore(state => state.setCropperAspectRatio) - const reset = useExtractStore(state => state.reset) function handleChangeAspectRatio(displayValue: string) { const value = aspectRatioList.find(v => v.displayValue === displayValue)?.value ?? -1 @@ -104,13 +97,6 @@ export const ExtractSectionDialog = ({ file }: Props) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]) - useEffect(() => { - if (!cropper) return - - cropper.reset() - reset() - }, [cropper, file, reset]) - return ( @@ -137,7 +123,10 @@ export const ExtractSectionDialog = ({ file }: Props) => { className={styles.image} /> + + { gap='2' width='100%' > - - - - +