mirror of
https://git.um-react.app/um/um-react.git
synced 2025-11-28 03:23:02 +00:00
Merge remote-tracking branch 'origin/main' into feat/file-row
# Conflicts: # src/features/file-listing/FileRow.tsx
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { InfoOutlineIcon } from '@chakra-ui/icons';
|
||||
import { Tooltip, VStack, Text, Flex } from '@chakra-ui/react';
|
||||
import { workerClientBus } from './decrypt-worker/client';
|
||||
import { workerClientBus } from './decrypt-worker/client.ts';
|
||||
import { DECRYPTION_WORKER_ACTION_NAME } from './decrypt-worker/constants';
|
||||
|
||||
import usePromise from 'react-promise-suspense';
|
||||
|
||||
9
src/assets/no-cover.svg
Normal file
9
src/assets/no-cover.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="160" height="160" viewBox="0 0 160 160">
|
||||
<rect fill="#ddd" width="160" height="160" />
|
||||
<text fill="#0007" font-family="sans-serif" font-size="24" font-weight="bold"
|
||||
text-anchor="middle" letter-spacing="6"
|
||||
dy="9.45" x="50%" y="50%"
|
||||
>
|
||||
暂无封面
|
||||
</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 348 B |
@@ -1,22 +1,12 @@
|
||||
import { ConcurrentQueue } from '~/util/ConcurrentQueue';
|
||||
import { WorkerClientBus } from '~/util/WorkerEventBus';
|
||||
import { DECRYPTION_WORKER_ACTION_NAME } from './constants';
|
||||
import { DecryptionQueue } from '~/util/DecryptionQueue';
|
||||
|
||||
// TODO: Worker pool?
|
||||
export const workerClient = new Worker(new URL('./worker', import.meta.url), { type: 'module' });
|
||||
|
||||
// FIXME: report the error so is obvious to the user.
|
||||
workerClient.onerror = (err) => console.error(err);
|
||||
|
||||
class DecryptionQueue extends ConcurrentQueue<{ id: string; blobURI: string }> {
|
||||
constructor(private workerClientBus: WorkerClientBus<DECRYPTION_WORKER_ACTION_NAME>, maxQueue?: number) {
|
||||
super(maxQueue);
|
||||
}
|
||||
|
||||
async handler(item: { id: string; blobURI: string }): Promise<void> {
|
||||
return this.workerClientBus.request(DECRYPTION_WORKER_ACTION_NAME.DECRYPT, item.blobURI);
|
||||
}
|
||||
}
|
||||
workerClient.addEventListener('error', console.error);
|
||||
|
||||
export const workerClientBus = new WorkerClientBus<DECRYPTION_WORKER_ACTION_NAME>(workerClient);
|
||||
export const decryptionQueue = new DecryptionQueue(workerClientBus);
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { DecryptedAudioFile, deleteFile, ProcessState } from './fileListingSlice';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useAppDispatch } from '~/hooks';
|
||||
import coverFallback from '~/assets/no-cover.svg';
|
||||
|
||||
interface FileRowProps {
|
||||
id: string;
|
||||
@@ -28,6 +29,7 @@ export function FileRow({ id, file }: FileRowProps) {
|
||||
const { isOpen, onClose } = useDisclosure({ defaultIsOpen: true });
|
||||
const dispatch = useAppDispatch();
|
||||
const isDecrypted = file.state === ProcessState.COMPLETE;
|
||||
const metadata = file.metadata;
|
||||
|
||||
const nameWithoutExt = file.fileName.replace(/\.[a-z\d]{3,6}$/, '');
|
||||
const decryptedName = nameWithoutExt + '.' + file.ext;
|
||||
@@ -55,7 +57,7 @@ export function FileRow({ id, file }: FileRowProps) {
|
||||
|
||||
return (
|
||||
<Collapse in={isOpen} animateOpacity unmountOnExit startingHeight={0} style={{ width: '100%' }}>
|
||||
<Card w="full">
|
||||
<Card w="full" data-testid="file-row">
|
||||
<CardBody>
|
||||
<Grid
|
||||
templateAreas={{
|
||||
@@ -82,26 +84,33 @@ export function FileRow({ id, file }: FileRowProps) {
|
||||
>
|
||||
<GridItem area="cover">
|
||||
<Center w="160px" h="160px" m="auto">
|
||||
<Image
|
||||
boxSize='160px'
|
||||
objectFit='cover'
|
||||
src={file.metadata.cover}
|
||||
alt={file.metadata.album}
|
||||
fallbackSrc='https://via.placeholder.com/160'
|
||||
/>
|
||||
{metadata && (
|
||||
<Image
|
||||
objectFit="cover"
|
||||
src={metadata.cover}
|
||||
alt={`"${metadata.album}" 的专辑封面`}
|
||||
fallbackSrc={coverFallback}
|
||||
/>
|
||||
)}
|
||||
</Center>
|
||||
</GridItem>
|
||||
<GridItem area="title">
|
||||
<Box w="full" as="h4" fontWeight="semibold" mt="1" textAlign={{ base: 'center', md: 'left' }}>
|
||||
{file.metadata.name || nameWithoutExt}
|
||||
<span data-testid="audio-meta-song-name">{metadata?.name ?? nameWithoutExt}</span>
|
||||
</Box>
|
||||
</GridItem>
|
||||
<GridItem area="meta">
|
||||
{isDecrypted && (
|
||||
{isDecrypted && metadata && (
|
||||
<Box>
|
||||
<Text>专辑: {file.metadata.album}</Text>
|
||||
<Text>艺术家: {file.metadata.artist}</Text>
|
||||
<Text>专辑艺术家: {file.metadata.albumArtist}</Text>
|
||||
<Text>
|
||||
专辑: <span data-testid="audio-meta-album-name">{metadata.album}</span>
|
||||
</Text>
|
||||
<Text>
|
||||
艺术家: <span data-testid="audio-meta-song-artist">{metadata.artist}</span>
|
||||
</Text>
|
||||
<Text>
|
||||
专辑艺术家: <span data-testid="audio-meta-album-artist">{metadata.albumArtist}</span>
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</GridItem>
|
||||
|
||||
18
src/features/file-listing/__tests__/FileListing.test.tsx
Normal file
18
src/features/file-listing/__tests__/FileListing.test.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { FileListing } from '../FileListing';
|
||||
import { renderWithProviders, screen } from '~/test-utils/test-helper';
|
||||
import { ListingMode } from '../fileListingSlice';
|
||||
import { dummyFiles } from './__fixture__/file-list';
|
||||
|
||||
test('should be able to render a list of 3 items', () => {
|
||||
renderWithProviders(<FileListing />, {
|
||||
preloadedState: {
|
||||
fileListing: {
|
||||
displayMode: ListingMode.LIST,
|
||||
files: dummyFiles,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getAllByTestId('file-row')).toHaveLength(3);
|
||||
expect(screen.getByText('Für Alice')).toBeInTheDocument();
|
||||
});
|
||||
24
src/features/file-listing/__tests__/FileRow.test.tsx
Normal file
24
src/features/file-listing/__tests__/FileRow.test.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { renderWithProviders, screen } from '~/test-utils/test-helper';
|
||||
import { untouchedFile } from './__fixture__/file-list';
|
||||
import { FileRow } from '../FileRow';
|
||||
import { completedFile } from './__fixture__/file-list';
|
||||
|
||||
test('should render no metadata when unavailable', () => {
|
||||
renderWithProviders(<FileRow id="file://ready" file={untouchedFile} />);
|
||||
|
||||
expect(screen.getAllByTestId('file-row')).toHaveLength(1);
|
||||
expect(screen.getByTestId('audio-meta-song-name')).toHaveTextContent('ready');
|
||||
expect(screen.queryByTestId('audio-meta-album-name')).toBeFalsy();
|
||||
expect(screen.queryByTestId('audio-meta-song-artist')).toBeFalsy();
|
||||
expect(screen.queryByTestId('audio-meta-album-artist')).toBeFalsy();
|
||||
});
|
||||
|
||||
test('should render metadata when file has been processed', () => {
|
||||
renderWithProviders(<FileRow id="file://done" file={completedFile} />);
|
||||
|
||||
expect(screen.getAllByTestId('file-row')).toHaveLength(1);
|
||||
expect(screen.getByTestId('audio-meta-song-name')).toHaveTextContent('Für Alice');
|
||||
expect(screen.getByTestId('audio-meta-album-name')).toHaveTextContent("NOW That's What I Call Cryptography 2023");
|
||||
expect(screen.getByTestId('audio-meta-song-artist')).toHaveTextContent('Jixun');
|
||||
expect(screen.getByTestId('audio-meta-album-artist')).toHaveTextContent('Cipher Lovers');
|
||||
});
|
||||
43
src/features/file-listing/__tests__/__fixture__/file-list.ts
Normal file
43
src/features/file-listing/__tests__/__fixture__/file-list.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { DecryptedAudioFile, ProcessState } from '../../fileListingSlice';
|
||||
|
||||
export const untouchedFile: DecryptedAudioFile = {
|
||||
fileName: 'ready.bin',
|
||||
raw: 'blob://localhost/file-a',
|
||||
decrypted: '',
|
||||
ext: '',
|
||||
state: ProcessState.UNTOUCHED,
|
||||
errorMessage: null,
|
||||
metadata: null,
|
||||
};
|
||||
|
||||
export const completedFile: DecryptedAudioFile = {
|
||||
fileName: 'hello-b.bin',
|
||||
raw: 'blob://localhost/file-b',
|
||||
decrypted: 'blob://localhost/file-b-decrypted',
|
||||
ext: 'flac',
|
||||
state: ProcessState.COMPLETE,
|
||||
errorMessage: null,
|
||||
metadata: {
|
||||
name: 'Für Alice',
|
||||
artist: 'Jixun',
|
||||
albumArtist: 'Cipher Lovers',
|
||||
album: "NOW That's What I Call Cryptography 2023",
|
||||
cover: '',
|
||||
},
|
||||
};
|
||||
|
||||
export const fileWithError: DecryptedAudioFile = {
|
||||
fileName: 'hello-c.bin',
|
||||
raw: 'blob://localhost/file-c',
|
||||
decrypted: 'blob://localhost/file-c-decrypted',
|
||||
ext: 'flac',
|
||||
state: ProcessState.ERROR,
|
||||
errorMessage: 'Could not decrypt blah blah',
|
||||
metadata: null,
|
||||
};
|
||||
|
||||
export const dummyFiles: Record<string, DecryptedAudioFile> = {
|
||||
'file://untouched': untouchedFile,
|
||||
'file://completed': completedFile,
|
||||
'file://error': fileWithError,
|
||||
};
|
||||
@@ -31,7 +31,7 @@ export interface DecryptedAudioFile {
|
||||
decrypted: string; // blob uri
|
||||
state: ProcessState;
|
||||
errorMessage: null | string;
|
||||
metadata: AudioMetadata;
|
||||
metadata: null | AudioMetadata;
|
||||
}
|
||||
|
||||
export interface FileListingState {
|
||||
@@ -69,13 +69,7 @@ export const fileListingSlice = createSlice({
|
||||
ext: '',
|
||||
state: ProcessState.UNTOUCHED,
|
||||
errorMessage: null,
|
||||
metadata: {
|
||||
name: '',
|
||||
artist: '',
|
||||
album: '',
|
||||
albumArtist: '',
|
||||
cover: '',
|
||||
},
|
||||
metadata: null,
|
||||
};
|
||||
},
|
||||
setDecryptedContent: (state, { payload }: PayloadAction<{ id: string; decryptedBlobURI: string }>) => {
|
||||
|
||||
@@ -4,9 +4,12 @@ import App from './App';
|
||||
|
||||
import { ChakraProvider } from '@chakra-ui/react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { store } from './store';
|
||||
import { setupStore } from './store';
|
||||
import { theme } from './theme';
|
||||
|
||||
// Private to this file only.
|
||||
const store = setupStore();
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<ChakraProvider theme={theme}>
|
||||
|
||||
21
src/store.ts
21
src/store.ts
@@ -1,11 +1,16 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import fileListing from './features/file-listing/fileListingSlice';
|
||||
import { PreloadedState, combineReducers, configureStore } from '@reduxjs/toolkit';
|
||||
import fileListingReducer from './features/file-listing/fileListingSlice';
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
fileListing: fileListing,
|
||||
},
|
||||
const rootReducer = combineReducers({
|
||||
fileListing: fileListingReducer,
|
||||
});
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
export const setupStore = (preloadedState?: PreloadedState<RootState>) =>
|
||||
configureStore({
|
||||
reducer: rootReducer,
|
||||
preloadedState,
|
||||
});
|
||||
|
||||
export type RootState = ReturnType<typeof rootReducer>;
|
||||
export type AppStore = ReturnType<typeof setupStore>;
|
||||
export type AppDispatch = AppStore['dispatch'];
|
||||
|
||||
@@ -1 +1,16 @@
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
// FIXME: Use something like jsdom-worker?
|
||||
// see: https://github.com/developit/jsdom-worker
|
||||
if (!global.Worker) {
|
||||
(global as any).Worker = class MockWorker {
|
||||
events: Record<string, (e: unknown) => void> = Object.create(null);
|
||||
|
||||
onmessage?: () => {};
|
||||
addEventListener(name: string, e: unknown) {
|
||||
if (Object.hasOwn(this.events, name)) {
|
||||
this.events[name](e);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
25
src/test-utils/test-helper.tsx
Normal file
25
src/test-utils/test-helper.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { PreloadedState } from '@reduxjs/toolkit';
|
||||
import { RenderOptions, render } from '@testing-library/react';
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { AppStore, RootState, setupStore } from '~/store';
|
||||
|
||||
// Adapted from: https://redux.js.org/usage/writing-tests
|
||||
|
||||
export * from '@testing-library/react';
|
||||
|
||||
export interface ExtendedRenderOptions extends RenderOptions {
|
||||
preloadedState?: PreloadedState<RootState>;
|
||||
store?: AppStore;
|
||||
}
|
||||
|
||||
export function renderWithProviders(
|
||||
ui: React.ReactElement,
|
||||
{ preloadedState = {}, store = setupStore(preloadedState), ...renderOptions }: ExtendedRenderOptions = {}
|
||||
) {
|
||||
function Wrapper({ children }: PropsWithChildren<{}>): JSX.Element {
|
||||
return <Provider store={store}>{children}</Provider>;
|
||||
}
|
||||
|
||||
return { store, ...render(ui, { wrapper: Wrapper, ...renderOptions }) };
|
||||
}
|
||||
13
src/util/DecryptionQueue.ts
Normal file
13
src/util/DecryptionQueue.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { DECRYPTION_WORKER_ACTION_NAME } from '~/decrypt-worker/constants';
|
||||
import { ConcurrentQueue } from './ConcurrentQueue';
|
||||
import { WorkerClientBus } from './WorkerEventBus';
|
||||
|
||||
export class DecryptionQueue extends ConcurrentQueue<{ id: string; blobURI: string }> {
|
||||
constructor(private workerClientBus: WorkerClientBus<DECRYPTION_WORKER_ACTION_NAME>, maxQueue?: number) {
|
||||
super(maxQueue);
|
||||
}
|
||||
|
||||
async handler(item: { id: string; blobURI: string }): Promise<void> {
|
||||
return this.workerClientBus.request(DECRYPTION_WORKER_ACTION_NAME.DECRYPT, item.blobURI);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user